Bug 1216537 - Check and request storage permission if file download is started. r?nalexander,paolo
--- a/toolkit/components/jsdownloads/src/DownloadCore.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadCore.jsm
@@ -455,16 +455,22 @@ this.Download.prototype = {
}
try {
// Disallow download if parental controls service restricts it.
if (yield DownloadIntegration.shouldBlockForParentalControls(this)) {
throw new DownloadError({ becauseBlockedByParentalControls: true });
}
+ // Disallow download if needed runtime permissions have not been granted
+ // by user.
+ if (yield DownloadIntegration.shouldBlockForRuntimePermissions()) {
+ throw new DownloadError({ becauseBlockedByRuntimePermissions: true });
+ }
+
// We should check if we have been canceled in the meantime, after all
// the previous asynchronous operations have been executed and just
// before we call the "execute" method of the saver.
if (this._promiseCanceled) {
// The exception will become a cancellation in the "catch" block.
throw undefined;
}
@@ -1490,17 +1496,18 @@ this.DownloadError = function (aProperti
// Set the error name used by the Error object prototype first.
this.name = "DownloadError";
this.result = aProperties.result || Cr.NS_ERROR_FAILURE;
if (aProperties.message) {
this.message = aProperties.message;
} else if (aProperties.becauseBlocked ||
aProperties.becauseBlockedByParentalControls ||
- aProperties.becauseBlockedByReputationCheck) {
+ aProperties.becauseBlockedByReputationCheck ||
+ aProperties.becauseBlockedByRuntimePermissions) {
this.message = "Download blocked.";
} else {
let exception = new Components.Exception("", this.result);
this.message = exception.toString();
}
if (aProperties.inferCause) {
let module = ((this.result & 0x7FFF0000) >> 16) -
NS_ERROR_MODULE_BASE_OFFSET;
@@ -1517,16 +1524,19 @@ this.DownloadError = function (aProperti
}
if (aProperties.becauseBlockedByParentalControls) {
this.becauseBlocked = true;
this.becauseBlockedByParentalControls = true;
} else if (aProperties.becauseBlockedByReputationCheck) {
this.becauseBlocked = true;
this.becauseBlockedByReputationCheck = true;
+ } else if (aProperties.becauseBlockedByRuntimePermissions) {
+ this.becauseBlocked = true;
+ this.becauseBlockedByRuntimePermissions = true;
} else if (aProperties.becauseBlocked) {
this.becauseBlocked = true;
}
if (aProperties.innerException) {
this.innerException = aProperties.innerException;
}
@@ -1565,16 +1575,25 @@ this.DownloadError.prototype = {
/**
* Indicates the download was blocked because it failed the reputation check
* and may be malware.
*/
becauseBlockedByReputationCheck: false,
/**
+ * Indicates the download was blocked because a runtime permission required to
+ * download files was not granted.
+ *
+ * This does not apply to all systems. On Android this flag is set to true if
+ * a needed runtime permission (storage) has not been granted by the user.
+ */
+ becauseBlockedByRuntimePermissions: false,
+
+ /**
* If this DownloadError was caused by an exception this property will
* contain the original exception. This will not be serialized when saving
* to the store.
*/
innerException: null,
/**
* Returns a static representation of the current object state.
@@ -1586,16 +1605,17 @@ this.DownloadError.prototype = {
let serializable = {
result: this.result,
message: this.message,
becauseSourceFailed: this.becauseSourceFailed,
becauseTargetFailed: this.becauseTargetFailed,
becauseBlocked: this.becauseBlocked,
becauseBlockedByParentalControls: this.becauseBlockedByParentalControls,
becauseBlockedByReputationCheck: this.becauseBlockedByReputationCheck,
+ becauseBlockedByRuntimePermissions: this.becauseBlockedByRuntimePermissions,
};
serializeUnknownProperties(this, serializable);
return serializable;
},
};
/**
@@ -1610,17 +1630,18 @@ this.DownloadError.fromSerializable = fu
let e = new DownloadError(aSerializable);
deserializeUnknownProperties(e, aSerializable, property =>
property != "result" &&
property != "message" &&
property != "becauseSourceFailed" &&
property != "becauseTargetFailed" &&
property != "becauseBlocked" &&
property != "becauseBlockedByParentalControls" &&
- property != "becauseBlockedByReputationCheck");
+ property != "becauseBlockedByReputationCheck" &&
+ property != "becauseBlockedByRuntimePermissions");
return e;
};
////////////////////////////////////////////////////////////////////////////////
//// DownloadSaver
/**
@@ -2311,24 +2332,28 @@ this.DownloadLegacySaver.prototype = {
*
* @param aCurrentBytes
* Number of bytes transferred until now.
* @param aTotalBytes
* Total number of bytes to be transferred, or -1 if unknown.
*/
onProgressBytes: function DLS_onProgressBytes(aCurrentBytes, aTotalBytes)
{
+ this.progressWasNotified = true;
+
// Ignore progress notifications until we are ready to process them.
if (!this.setProgressBytesFn) {
+ // Keep the data from the last progress notification that was received.
+ this.currentBytes = aCurrentBytes;
+ this.totalBytes = aTotalBytes;
return;
}
let hasPartFile = !!this.download.target.partFilePath;
- this.progressWasNotified = true;
this.setProgressBytesFn(aCurrentBytes, aTotalBytes,
aCurrentBytes > 0 && hasPartFile);
},
/**
* Whether the onProgressBytes function has been called at least once.
*/
progressWasNotified: false,
@@ -2428,16 +2453,19 @@ this.DownloadLegacySaver.prototype = {
this.copySaver.download = this.download;
this.copySaver.entityID = this.entityID;
this.copySaver.alreadyAddedToHistory = true;
}
return this.copySaver.execute.apply(this.copySaver, arguments);
}
this.setProgressBytesFn = aSetProgressBytesFn;
+ if (this.progressWasNotified) {
+ this.onProgressBytes(this.currentBytes, this.totalBytes);
+ }
return Task.spawn(function* task_DLS_execute() {
try {
// Wait for the component that executes the download to finish.
yield this.deferExecuted.promise;
// At this point, the "request" property has been populated. Ensure we
// report the value of "Content-Length", if available, even if the
--- a/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
+++ b/toolkit/components/jsdownloads/src/DownloadIntegration.jsm
@@ -63,16 +63,20 @@ XPCOMUtils.defineLazyServiceGetter(this,
"@mozilla.org/process/environment;1",
"nsIEnvironment");
XPCOMUtils.defineLazyServiceGetter(this, "gMIMEService",
"@mozilla.org/mime;1",
"nsIMIMEService");
XPCOMUtils.defineLazyServiceGetter(this, "gExternalProtocolService",
"@mozilla.org/uriloader/external-protocol-service;1",
"nsIExternalProtocolService");
+#ifdef MOZ_WIDGET_ANDROID
+XPCOMUtils.defineLazyModuleGetter(this, "RuntimePermissions",
+ "resource://gre/modules/RuntimePermissions.jsm");
+#endif
XPCOMUtils.defineLazyGetter(this, "gParentalControlsService", function() {
if ("@mozilla.org/parental-controls-service;1" in Cc) {
return Cc["@mozilla.org/parental-controls-service;1"]
.createInstance(Ci.nsIParentalControlsService);
}
return null;
});
@@ -131,16 +135,18 @@ const kObserverTopics = [
this.DownloadIntegration = {
// For testing only
_testMode: false,
testPromptDownloads: 0,
dontLoadList: false,
dontLoadObservers: false,
dontCheckParentalControls: false,
shouldBlockInTest: false,
+ dontCheckRuntimePermissions: false,
+ shouldBlockInTestForRuntimePermissions: false,
#ifdef MOZ_URL_CLASSIFIER
dontCheckApplicationReputation: false,
#else
dontCheckApplicationReputation: true,
#endif
shouldBlockInTestForApplicationReputation: false,
shouldKeepBlockedDataInTest: false,
dontOpenFileAndFolder: false,
@@ -494,16 +500,35 @@ this.DownloadIntegration = {
shouldBlock,
NetUtil.newURI(aDownload.source.url), null);
}
return Promise.resolve(shouldBlock);
},
/**
+ * Checks to determine whether to block downloads for not granted runtime permissions.
+ *
+ * @return {Promise}
+ * @resolves The boolean indicates to block downloads or not.
+ */
+ shouldBlockForRuntimePermissions: function DI_shouldBlockForRuntimePermissions() {
+ if (this.dontCheckRuntimePermissions) {
+ return Promise.resolve(this.shouldBlockInTestForRuntimePermissions);
+ }
+
+#ifdef MOZ_WIDGET_ANDROID
+ return RuntimePermissions.waitForPermissions(RuntimePermissions.WRITE_EXTERNAL_STORAGE)
+ .then(permissionGranted => !permissionGranted);
+#else
+ return Promise.resolve(false);
+#endif
+ },
+
+ /**
* Checks to determine whether to block downloads because they might be
* malware, based on application reputation checks.
*
* aParam aDownload
* The download object.
*
* @return {Promise}
* @resolves The boolean indicates to block downloads or not.
--- a/toolkit/components/jsdownloads/test/unit/common_test_Download.js
+++ b/toolkit/components/jsdownloads/test/unit/common_test_Download.js
@@ -1636,16 +1636,57 @@ add_task(function* test_blocked_parental
do_check_true(download.error.becauseBlockedByParentalControls);
do_check_true(download.stopped);
}
do_check_false(yield OS.File.exists(download.target.path));
});
/**
+ * Download with runtime permissions
+ */
+add_task(function* test_blocked_runtime_permissions()
+{
+ function cleanup() {
+ DownloadIntegration.shouldBlockInTestForRuntimePermissions = false;
+ }
+ do_register_cleanup(cleanup);
+ DownloadIntegration.shouldBlockInTestForRuntimePermissions = true;
+
+ let download;
+ try {
+ if (!gUseLegacySaver) {
+ // When testing DownloadCopySaver, we want to check that the promise
+ // returned by the "start" method is rejected.
+ download = yield promiseNewDownload();
+ yield download.start();
+ } else {
+ // When testing DownloadLegacySaver, we cannot be sure whether we are
+ // testing the promise returned by the "start" method or we are testing
+ // the "error" property checked by promiseDownloadStopped. This happens
+ // because we don't have control over when the download is started.
+ download = yield promiseStartLegacyDownload();
+ yield promiseDownloadStopped(download);
+ }
+ do_throw("The download should have blocked.");
+ } catch (ex) {
+ if (!(ex instanceof Downloads.Error) || !ex.becauseBlocked) {
+ throw ex;
+ }
+ do_check_true(ex.becauseBlockedByRuntimePermissions);
+ do_check_true(download.error.becauseBlockedByRuntimePermissions);
+ }
+
+ // Now that the download stopped, the target file should not exist.
+ do_check_false(yield OS.File.exists(download.target.path));
+
+ cleanup();
+});
+
+/**
* Check that DownloadCopySaver can always retrieve the hash.
* DownloadLegacySaver can only retrieve the hash when
* nsIExternalHelperAppService is invoked.
*/
add_task(function* test_getSha256Hash()
{
if (!gUseLegacySaver) {
let download = yield promiseStartDownload(httpUrl("source.txt"));
--- a/toolkit/components/jsdownloads/test/unit/head.js
+++ b/toolkit/components/jsdownloads/test/unit/head.js
@@ -793,16 +793,18 @@ add_task(function test_common_initialize
// Disable the parental controls checking.
DownloadIntegration.dontCheckParentalControls = true;
// Disable application reputation checks.
DownloadIntegration.dontCheckApplicationReputation = true;
// Disable the calls to the OS to launch files and open containing folders
DownloadIntegration.dontOpenFileAndFolder = true;
DownloadIntegration._deferTestOpenFile = Promise.defer();
DownloadIntegration._deferTestShowDir = Promise.defer();
+ // Disable checking runtime permissions.
+ DownloadIntegration.dontCheckRuntimePermissions = true;
// Avoid leaking uncaught promise errors
DownloadIntegration._deferTestOpenFile.promise.then(null, () => undefined);
DownloadIntegration._deferTestShowDir.promise.then(null, () => undefined);
// Get a reference to nsIComponentRegistrar, and ensure that is is freed
// before the XPCOM shutdown.
let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);