forked from mirrors/gecko-dev
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:
parent
c245e071f7
commit
0ff7359c48
10 changed files with 433 additions and 4 deletions
|
|
@ -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"),
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ addons = {
|
|||
"download.js",
|
||||
"manifest.json",
|
||||
],
|
||||
"download-onChanged": [
|
||||
"download.js",
|
||||
"manifest.json",
|
||||
],
|
||||
}
|
||||
|
||||
for addon, files in addons.items():
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue