Bug 1684923 - Implement Web Extension downloads.onChanged event in GeckoView r=agi,robwu,geckoview-reviewers,esawin

Differential Revision: https://phabricator.services.mozilla.com/D101377
This commit is contained in:
owlishDeveloper 2021-02-22 23:54:22 +00:00
parent c245e071f7
commit 0ff7359c48
10 changed files with 433 additions and 4 deletions

View file

@ -10,6 +10,9 @@ ChromeUtils.defineModuleGetter(
"DownloadPaths",
"resource://gre/modules/DownloadPaths.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
DownloadTracker: "resource://gre/modules/GeckoViewWebExtension.jsm",
});
var { ignoreEvent } = ExtensionCommon;
@ -51,8 +54,43 @@ const STATE_MAP = new Map([
[2, State.COMPLETE],
]);
const INTERRUPT_REASON_MAP = new Map([
[0, undefined],
[1, "FILE_FAILED"],
[2, "FILE_ACCESS_DENIED"],
[3, "FILE_NO_SPACE"],
[4, "FILE_NAME_TOO_LONG"],
[5, "FILE_TOO_LARGE"],
[6, "FILE_VIRUS_INFECTED"],
[7, "FILE_TRANSIENT_ERROR"],
[8, "FILE_BLOCKED"],
[9, "FILE_SECURITY_CHECK_FAILED"],
[10, "FILE_TOO_SHORT"],
[11, "NETWORK_FAILED"],
[12, "NETWORK_TIMEOUT"],
[13, "NETWORK_DISCONNECTED"],
[14, "NETWORK_SERVER_DOWN"],
[15, "NETWORK_INVALID_REQUEST"],
[16, "SERVER_FAILED"],
[17, "SERVER_NO_RANGE"],
[18, "SERVER_BAD_CONTENT"],
[19, "SERVER_UNAUTHORIZED"],
[20, "SERVER_CERT_PROBLEM"],
[21, "SERVER_FORBIDDEN"],
[22, "USER_CANCELED"],
[23, "USER_SHUTDOWN"],
[24, "CRASH"],
]);
// TODO Bug 1247794: make id and extension info persistent
class DownloadItem {
/**
* Initializes an object that represents a download
*
* @param {Object} downloadInfo - an object from Java when creating a download
* @param {Object} options - an object passed in to download() function
* @param {Extension} extension - instance of an extension object
*/
constructor(downloadInfo, options, extension) {
this.id = downloadInfo.id;
this.url = options.url;
@ -72,6 +110,41 @@ class DownloadItem {
this.byExtensionId = extension?.id;
this.byExtensionName = extension?.name;
}
/**
* This function updates the download item it was called on.
*
* @param {Object} data that arrived from the app (Java)
* @returns {Object|null} an object of <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/onChanged#downloaddelta>downloadDelta type</a>
*/
update(data) {
const { downloadItemId } = data;
const delta = {};
data.state = STATE_MAP.get(data.state);
data.error = INTERRUPT_REASON_MAP.get(data.error);
delete data.downloadItemId;
let changed = false;
for (const prop in data) {
const current = data[prop] ?? null;
const previous = this[prop] ?? null;
if (current !== previous) {
delta[prop] = { current, previous };
this[prop] = current;
changed = true;
}
}
// Don't send empty onChange events
if (!changed) {
return null;
}
delta.id = downloadItemId;
return delta;
}
}
this.downloads = class extends ExtensionAPI {
@ -141,6 +214,7 @@ this.downloads = class extends ExtensionAPI {
})
.then(value => {
const downloadItem = new DownloadItem(value, options, extension);
DownloadTracker.addDownloadItem(downloadItem);
return downloadItem.id;
});
},
@ -185,7 +259,23 @@ this.downloads = class extends ExtensionAPI {
throw new ExtensionError("Not implemented");
},
onChanged: ignoreEvent(context, "downloads.onChanged"),
onChanged: new EventManager({
context,
name: "downloads.onChanged",
register: fire => {
const listener = (eventName, event) => {
const { delta, downloadItem } = event;
if (context.privateBrowsingAllowed || !downloadItem.incognito) {
fire.async(delta);
}
};
DownloadTracker.on("download-changed", listener);
return () => {
DownloadTracker.off("download-changed", listener);
};
},
}).api(),
onCreated: ignoreEvent(context, "downloads.onCreated"),

View file

@ -200,6 +200,11 @@ class GeckoViewStartup {
}
);
GeckoViewUtils.addLazyGetter(this, "DownloadTracker", {
module: "resource://gre/modules/GeckoViewWebExtension.jsm",
ged: ["GeckoView:WebExtension:DownloadChanged"],
});
ChromeUtils.import("resource://gre/modules/NotificationDB.jsm");
// Initialize safe browsing module. This is required for content

View file

@ -1728,6 +1728,7 @@ package org.mozilla.geckoview {
public static class WebExtension.Download {
ctor protected Download(int);
method @Nullable @UiThread public GeckoResult<Void> update(@NonNull WebExtension.Download.Info);
field public static final int INTERRUPT_REASON_CRASH = 24;
field public static final int INTERRUPT_REASON_FILE_ACCESS_DENIED = 2;
field public static final int INTERRUPT_REASON_FILE_BLOCKED = 8;

View file

@ -54,6 +54,10 @@ addons = {
"download.js",
"manifest.json",
],
"download-onChanged": [
"download.js",
"manifest.json",
],
}
for addon, files in addons.items():

View file

@ -0,0 +1,18 @@
async function test() {
browser.downloads.onChanged.addListener(async delta => {
const changes = { current: {}, previous: {} };
changes.id = delta.id;
delete delta.id;
for (const prop in delta) {
changes.current[prop] = delta[prop].current;
changes.previous[prop] = delta[prop].previous;
}
await browser.runtime.sendNativeMessage("browser", changes);
});
await browser.downloads.download({
url: "http://localhost:4245/assets/www/images/test.gif",
});
}
test();

View file

@ -0,0 +1,19 @@
{
"manifest_version": 2,
"name": "Download",
"version": "1.0",
"applications": {
"gecko": {
"id": "download-onChanged@tests.mozilla.org"
}
},
"description": "Downloads a file",
"background": {
"scripts": ["download.js"]
},
"permissions": [
"downloads",
"geckoViewAddons",
"nativeMessaging"
]
}

View file

@ -14,6 +14,7 @@ import org.junit.Assume.assumeThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.gecko.EventDispatcher
import org.mozilla.geckoview.*
import org.mozilla.geckoview.WebExtension.*
import org.mozilla.geckoview.WebExtension.BrowsingDataDelegate.Type.*
@ -2299,4 +2300,202 @@ class WebExtensionTest : BaseSessionTest() {
assertNotNull(downloadCreated.id)
sessionRule.waitForResult(controller.uninstall(webExtension))
}
@Test
fun testOnChanged() {
val uri = createTestUrl("/assets/www/images/test.gif")
val downloadId = 4
val unfinishedDownloadSize = 5L
val finishedDownloadSize = 25L
val expectedFilename = "test.gif"
val expectedMime = "image/gif"
val expectedEndTime = Date().time
val expectedFilesize = 48L
// first and second update
val downloadData = object : Download.Info {
var endTime : Long? = null
val startTime = Date().time - 50000
var fileExists = false
var totalBytes: Long = -1
var mime = ""
var fileSize: Long = -1
var filename = ""
var state = Download.STATE_IN_PROGRESS
override fun state(): Int {
return state
}
override fun endTime(): Long? {
return endTime
}
override fun startTime(): Long {
return startTime
}
override fun fileExists(): Boolean {
return fileExists;
}
override fun totalBytes(): Long {
return totalBytes
}
override fun mime(): String {
return mime
}
override fun fileSize(): Long {
return fileSize
}
override fun filename(): String {
return filename
}
}
val webExtension = sessionRule.waitForResult(
controller.installBuiltIn("resource://android/assets/web_extensions/download-onChanged/"))
val assertOnDownloadCalled = GeckoResult<Download>()
val downloadDelegate = object : DownloadDelegate {
override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<WebExtension.DownloadInitData>? {
assertEquals(webExtension!!.id, source.id)
assertEquals(uri, request.request.uri)
val download = controller.createDownload(downloadId)
assertOnDownloadCalled.complete(download)
return GeckoResult.fromValue(DownloadInitData(download, downloadData))
}
}
val updates = mutableListOf<JSONObject>()
val thirdUpdateReceived = GeckoResult<JSONObject>()
val messageDelegate = object : MessageDelegate {
override fun onMessage(nativeApp: String, message: Any, sender: MessageSender): GeckoResult<Any>? {
val current = (message as JSONObject).getJSONObject("current")
updates.add(message)
// Once we get the size finished download, that means we got the last update
if (current.getLong("totalBytes") == finishedDownloadSize) {
thirdUpdateReceived.complete(message)
}
return GeckoResult.fromValue(message)
}
}
webExtension.setDownloadDelegate(downloadDelegate)
webExtension.setMessageDelegate(messageDelegate, "browser")
mainSession.reload()
sessionRule.waitForPageStop()
val downloadCreated = sessionRule.waitForResult(assertOnDownloadCalled)
assertEquals(downloadId, downloadCreated.id)
// first and second update (they are identical)
downloadData.filename = expectedFilename
downloadData.mime = expectedMime
downloadData.totalBytes = unfinishedDownloadSize
downloadCreated.update(downloadData)
downloadCreated.update(downloadData)
downloadData.fileSize = expectedFilesize
downloadData.endTime = expectedEndTime
downloadData.totalBytes = finishedDownloadSize
downloadData.state = Download.STATE_COMPLETE
downloadCreated.update(downloadData)
sessionRule.waitForResult(thirdUpdateReceived)
// The second update should not be there because the data was identical
assertEquals(2, updates.size)
val firstUpdateCurrent = updates[0].getJSONObject("current")
val firstUpdatePrevious = updates[0].getJSONObject("previous")
assertEquals(3, firstUpdateCurrent.length())
assertEquals(3, firstUpdatePrevious.length())
assertEquals(expectedMime, firstUpdateCurrent.getString("mime"))
assertEquals("", firstUpdatePrevious.getString("mime"))
assertEquals(expectedFilename, firstUpdateCurrent.getString("filename"))
assertEquals("", firstUpdatePrevious.getString("filename"))
assertEquals(unfinishedDownloadSize, firstUpdateCurrent.getLong("totalBytes"))
assertEquals(-1, firstUpdatePrevious.getLong("totalBytes"))
val secondUpdateCurrent = updates[1].getJSONObject("current")
val secondUpdatePrevious = updates[1].getJSONObject("previous")
assertEquals(4, secondUpdateCurrent.length())
assertEquals(4, secondUpdatePrevious.length())
assertEquals(finishedDownloadSize, secondUpdateCurrent.getLong("totalBytes"))
assertEquals(firstUpdateCurrent.getLong("totalBytes"), secondUpdatePrevious.getLong("totalBytes"))
assertEquals("complete", secondUpdateCurrent.get("state").toString())
assertEquals("in_progress", secondUpdatePrevious.get("state").toString())
assertEquals(expectedEndTime.toString(), secondUpdateCurrent.getString("endTime"))
assertEquals("null", secondUpdatePrevious.getString("endTime"))
assertEquals(expectedFilesize, secondUpdateCurrent.getLong("fileSize"))
assertEquals(-1, secondUpdatePrevious.getLong("fileSize"))
sessionRule.waitForResult(controller.uninstall(webExtension))
}
@Test
fun testOnChangedWrongId() {
val uri = createTestUrl("/assets/www/images/test.gif")
val downloadId = 5
val webExtension = sessionRule.waitForResult(
controller.installBuiltIn("resource://android/assets/web_extensions/download-onChanged/"))
val assertOnDownloadCalled = GeckoResult<WebExtension.Download>()
val downloadDelegate = object : DownloadDelegate {
override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<WebExtension.DownloadInitData>? {
assertEquals(webExtension!!.id, source.id)
assertEquals(uri, request.request.uri)
val download = controller.createDownload(downloadId)
assertOnDownloadCalled.complete(download)
return GeckoResult.fromValue(DownloadInitData(download, object : Download.Info {}))
}
}
val onMessageCalled = GeckoResult<String>()
val messageDelegate = object : MessageDelegate {
override fun onMessage(nativeApp: String, message: Any, sender: MessageSender): GeckoResult<Any>? {
onMessageCalled.complete(message as String)
return GeckoResult.fromValue(message)
}
}
webExtension.setDownloadDelegate(downloadDelegate)
webExtension.setMessageDelegate(messageDelegate, "browser")
mainSession.reload()
sessionRule.waitForPageStop()
val updateData = object : WebExtension.Download.Info {
override fun state(): Int {
return WebExtension.Download.STATE_COMPLETE
}
}
val randomDownload = controller.createDownload(25)
val r = randomDownload!!.update(updateData)
try {
sessionRule.waitForResult(r!!)
} catch (ex: Exception) {
val a = ex.message!!
assertEquals("Error: Trying to update unknown download", a)
sessionRule.waitForResult(controller.uninstall(webExtension))
return
}
}
}

View file

@ -2300,8 +2300,47 @@ public class WebExtension {
/* package */ void setDelegate(final Delegate delegate) { }
/* package */ GeckoResult<Void> update(final Info data) {
return null;
/**
* Updates the download state.
* This will trigger a call to <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/onChanged">downloads.onChanged</a> event
* to the corresponding `DownloadItem` on the extension side.
*
* @param data - current metadata associated with the download. {@link Download.Info} implementation instance
* @return GeckoResult with nothing or error inside
*/
@Nullable
@UiThread
public GeckoResult<Void> update(final @NonNull Download.Info data) {
final GeckoBundle bundle = new GeckoBundle(12);
bundle.putInt("downloadItemId", this.id);
bundle.putString("filename", data.filename());
bundle.putString("mime", data.mime());
bundle.putString("startTime", String.valueOf(data.startTime()));
bundle.putString("endTime", data.endTime() == null ? null : String.valueOf(data.endTime()));
bundle.putInt("state", data.state());
bundle.putBoolean("canResume", data.canResume());
bundle.putBoolean("paused", data.paused());
Integer error = data.error();
if (error != null) {
bundle.putInt("error", error);
}
bundle.putLong("totalBytes", data.totalBytes());
bundle.putLong("fileSize", data.fileSize());
bundle.putBoolean("exists", data.fileExists());
return EventDispatcher.getInstance().queryVoid(
"GeckoView:WebExtension:DownloadChanged", bundle
).map(null, e -> {
if (e instanceof EventDispatcher.QueryException) {
EventDispatcher.QueryException queryException = (EventDispatcher.QueryException) e;
if (queryException.data instanceof String) {
return new IllegalArgumentException((String) queryException.data);
}
}
return e;
});
}
/* package */ interface Delegate {

View file

@ -13,6 +13,13 @@ exclude: true
⚠️ breaking change and deprecation notices
## v88
- Added [`WebExtension.Download#update`][88.1] that can be used to
implement the WebExtension `downloads` API. This method is used to communicate
updates in the download status to the Web Extension
[88.1]: {{javadoc_uri}}/WebExtension.Download.html#update-org.mozilla.geckoview.WebExtension.Download.Info-
## v87
- ⚠ Added [`WebExtension.DownloadInitData`][87.1] class that can be used to
implement the WebExtension `downloads` API. This class represents initial state of a download.
@ -896,4 +903,4 @@ to allow adding gecko profiler markers.
[65.24]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport-android.content.Context-android.os.Bundle-java.lang.String-
[65.25]: {{javadoc_uri}}/GeckoResult.html
[api-version]: d9171ae05286c279c35515eb3ac3e42258cec583
[api-version]: 2ca865a6a509dfc7100d38f906877c5467c43cd0

View file

@ -9,6 +9,7 @@ var EXPORTED_SYMBOLS = [
"GeckoViewConnection",
"GeckoViewWebExtension",
"mobileWindowTracker",
"DownloadTracker",
];
const { XPCOMUtils } = ChromeUtils.import(
@ -47,6 +48,52 @@ XPCOMUtils.defineLazyServiceGetter(
const { debug, warn } = GeckoViewUtils.initLogging("Console");
const DOWNLOAD_CHANGED_MESSAGE = "GeckoView:WebExtension:DownloadChanged";
var DownloadTracker = new (class extends EventEmitter {
constructor() {
super();
// maps numeric IDs to DownloadItem objects
this._downloads = new Map();
}
onEvent(event, data, callback) {
switch (event) {
case "GeckoView:WebExtension:DownloadChanged": {
const downloadItem = this.getDownloadItemById(data.downloadItemId);
if (!downloadItem) {
callback.onError("Error: Trying to update unknown download");
return;
}
const delta = downloadItem.update(data);
if (delta) {
this.emit("download-changed", {
delta,
downloadItem,
});
}
}
}
}
addDownloadItem(item) {
this._downloads.set(item.id, item);
}
/**
* Finds and returns a DownloadItem with a certain numeric ID
*
* @param {number} id
* @returns {DownloadItem} download item
*/
getDownloadItemById(id) {
return this._downloads.get(id);
}
})();
/** Provides common logic between page and browser actions */
class ExtensionActionHelper {
constructor({