diff --git a/mobile/android/geckoview/api.txt b/mobile/android/geckoview/api.txt index d05aad2a240d..9b396fd4aa34 100644 --- a/mobile/android/geckoview/api.txt +++ b/mobile/android/geckoview/api.txt @@ -1530,6 +1530,7 @@ package org.mozilla.geckoview { field public static final int ERROR_NETWORK_FAILURE = -1; field public static final int ERROR_SIGNEDSTATE_REQUIRED = -5; field public static final int ERROR_UNEXPECTED_ADDON_TYPE = -6; + field public static final int ERROR_USER_CANCELED = -100; } @UiThread public static interface WebExtension.MessageDelegate { @@ -1603,6 +1604,7 @@ package org.mozilla.geckoview { method @AnyThread public void setTabActive(@NonNull GeckoSession, boolean); method @UiThread public void setTabDelegate(@Nullable WebExtensionController.TabDelegate); method @NonNull @AnyThread public GeckoResult uninstall(@NonNull WebExtension); + method @AnyThread @NonNull public GeckoResult update(@NonNull WebExtension); } public static class WebExtensionController.EnableSource { @@ -1613,6 +1615,7 @@ package org.mozilla.geckoview { @UiThread public static interface WebExtensionController.PromptDelegate { method @Nullable default public GeckoResult onInstallPrompt(@NonNull WebExtension); + method @Nullable default public GeckoResult onUpdatePrompt(@NonNull WebExtension, @NonNull WebExtension, @NonNull String[], @NonNull String[]); } public static interface WebExtensionController.TabDelegate { diff --git a/mobile/android/geckoview/src/androidTest/assets/moz.build b/mobile/android/geckoview/src/androidTest/assets/moz.build new file mode 100644 index 000000000000..0d6ef7aa3dc9 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/moz.build @@ -0,0 +1,22 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +addons = [ + 'update-1', + 'update-2', + 'update-with-perms-1', + 'update-with-perms-2', +] + +for addon in addons: + indir = 'web_extensions/%s' % addon + xpi = '%s.xpi' % indir + + GENERATED_FILES += [xpi] + GENERATED_FILES[xpi].script = '../../../../../../toolkit/mozapps/extensions/test/create_xpi.py' + GENERATED_FILES[xpi].inputs = [indir] + + TEST_HARNESS_FILES.testing.mochitest.tests.junit += ['!%s' % xpi] diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/borderify.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/borderify.js new file mode 100644 index 000000000000..9c3728b381bc --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/borderify.js @@ -0,0 +1 @@ +document.body.style.border = "5px solid red"; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/icons/border-48.png b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/icons/border-48.png new file mode 100644 index 000000000000..90687de26d71 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/icons/border-48.png differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/icons/icon.svg b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/icons/icon.svg new file mode 100644 index 000000000000..dd1fae7d150d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/icons/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/manifest.json new file mode 100644 index 000000000000..8852de7174ee --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/manifest.json @@ -0,0 +1,21 @@ +{ + "manifest_version": 2, + "name": "update", + "browser_specific_settings": { + "gecko": { + "id": "update@example.com", + "update_url": "https://example.org/tests/junit/update_manifest.json" + } + }, + "version": "1.0", + "description": "Adds a red border to all webpages matching example.com.", + "icons": { + "48": "icons/border-48.png" + }, + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["borderify.js"] + } + ] +} \ No newline at end of file diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/borderify.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/borderify.js new file mode 100644 index 000000000000..3529928d82e5 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/borderify.js @@ -0,0 +1 @@ +document.body.style.border = "5px solid blue"; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/icons/border-48.png b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/icons/border-48.png new file mode 100644 index 000000000000..90687de26d71 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/icons/border-48.png differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/icons/icon.svg b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/icons/icon.svg new file mode 100644 index 000000000000..dd1fae7d150d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/icons/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/manifest.json new file mode 100644 index 000000000000..90d95591b51b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/manifest.json @@ -0,0 +1,20 @@ +{ + "manifest_version": 2, + "name": "update", + "browser_specific_settings": { + "gecko": { + "id": "update@example.com" + } + }, + "version": "2.0", + "description": "Adds a blue border to all webpages matching example.com.", + "icons": { + "48": "icons/border-48.png" + }, + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["borderify.js"] + } + ] +} \ No newline at end of file diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/borderify.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/borderify.js new file mode 100644 index 000000000000..9c3728b381bc --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/borderify.js @@ -0,0 +1 @@ +document.body.style.border = "5px solid red"; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/icons/border-48.png b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/icons/border-48.png new file mode 100644 index 000000000000..90687de26d71 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/icons/border-48.png differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/icons/icon.svg b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/icons/icon.svg new file mode 100644 index 000000000000..dd1fae7d150d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/icons/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/manifest.json new file mode 100644 index 000000000000..fc9a7f52916a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/manifest.json @@ -0,0 +1,21 @@ +{ + "manifest_version": 2, + "name": "update", + "browser_specific_settings": { + "gecko": { + "id": "update-with-perms@example.com", + "update_url": "https://example.org/tests/junit/update_manifest.json" + } + }, + "version": "1.0", + "description": "Adds a red border to all webpages matching example.com.", + "icons": { + "48": "icons/border-48.png" + }, + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["borderify.js"] + } + ] +} \ No newline at end of file diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/borderify.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/borderify.js new file mode 100644 index 000000000000..3529928d82e5 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/borderify.js @@ -0,0 +1 @@ +document.body.style.border = "5px solid blue"; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/icons/border-48.png b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/icons/border-48.png new file mode 100644 index 000000000000..90687de26d71 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/icons/border-48.png differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/icons/icon.svg b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/icons/icon.svg new file mode 100644 index 000000000000..dd1fae7d150d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/icons/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/manifest.json new file mode 100644 index 000000000000..173f7ea79f49 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 2, + "name": "update", + "browser_specific_settings": { + "gecko": { + "id": "update-with-perms@example.com" + } + }, + "version": "2.0", + "description": "Adds a blue border to all webpages matching example.com.", + "icons": { + "48": "icons/border-48.png" + }, + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["borderify.js"] + } + ], + "permissions": [ + "tabs" + ] +} \ No newline at end of file diff --git a/mobile/android/geckoview/src/androidTest/assets/www/update_manifest.json b/mobile/android/geckoview/src/androidTest/assets/www/update_manifest.json new file mode 100644 index 000000000000..743c8568f382 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/update_manifest.json @@ -0,0 +1,28 @@ +{ + "addons": { + "update@example.com": { + "updates": [ + { + "version": "1.0", + "update_link": "https://example.org/tests/junit/update-1.xpi" + }, + { + "version": "2.0", + "update_link": "https://example.org/tests/junit/update-2.xpi" + } + ] + }, + "update-with-perms@example.com": { + "updates": [ + { + "version": "1.0", + "update_link": "https://example.org/tests/junit/update-with-perms-1.xpi" + }, + { + "version": "2.0", + "update_link": "https://example.org/tests/junit/update-with-perms-2.xpi" + } + ] + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt index 19d0a154ecbb..afd389c1eb4f 100644 --- a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt @@ -9,11 +9,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import org.hamcrest.core.StringEndsWith.endsWith import org.hamcrest.core.IsEqual.equalTo import org.json.JSONObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertTrue -import org.junit.Assert.fail +import org.junit.Assert.* import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -1009,4 +1005,239 @@ class WebExtensionTest : BaseSessionTest() { fail("The above code should throw.") } + + // Test the basic update extension flow with no new permissions. + @Test + fun update() { + sessionRule.setPrefsUntilTestEnd(mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false + )) + mainSession.loadUri("example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + assertEquals(extension.metaData!!.version, "1.0") + + return GeckoResult.fromValue(AllowOrDeny.ALLOW) + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-1.xpi")) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + val update2 = sessionRule.waitForResult(controller.update(update1)); + assertEquals(update2.metaData!!.version, "2.0") + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that updated extension changed the border color. + assertBodyBorderEqualTo("blue") + + // Unregister WebExtension and check again + sessionRule.waitForResult(controller.uninstall(update2)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being unregistered + assertBodyBorderEqualTo("") + } + + // Test extension updating when the new extension has different permissions. + @Test + fun updateWithPerms() { + sessionRule.setPrefsUntilTestEnd(mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false + )) + mainSession.loadUri("example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + assertEquals(extension.metaData!!.version, "1.0") + + return GeckoResult.fromValue(AllowOrDeny.ALLOW) + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-with-perms-1.xpi")) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onUpdatePrompt(currentlyInstalled: WebExtension, + updatedExtension: WebExtension, + newPermissions: Array, + newOrigins: Array): GeckoResult { + assertEquals(currentlyInstalled.metaData!!.version, "1.0") + assertEquals(updatedExtension.metaData!!.version, "2.0") + assertEquals(newPermissions.size, 1) + assertEquals(newPermissions[0], "tabs") + return GeckoResult.fromValue(AllowOrDeny.ALLOW); + } + }) + + val update2 = sessionRule.waitForResult(controller.update(update1)); + assertEquals(update2.metaData!!.version, "2.0") + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that updated extension changed the border color. + assertBodyBorderEqualTo("blue") + + // Unregister WebExtension and check again + sessionRule.waitForResult(controller.uninstall(update2)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being unregistered + assertBodyBorderEqualTo("") + } + + // Ensure update extension works as expected when there is no update available. + @Test + fun updateNotAvailable() { + sessionRule.setPrefsUntilTestEnd(mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false + )) + mainSession.loadUri("example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + assertEquals(extension.metaData!!.version, "2.0") + + return GeckoResult.fromValue(AllowOrDeny.ALLOW) + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-2.xpi")) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("blue") + + val update2 = sessionRule.waitForResult(controller.update(update1)) + assertNull(update2); + + // Unregister WebExtension and check again + sessionRule.waitForResult(controller.uninstall(update1)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being unregistered + assertBodyBorderEqualTo("") + } + + // Test denying an extension update. + @Test + fun updateDenyPerms() { + sessionRule.setPrefsUntilTestEnd(mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false + )) + mainSession.loadUri("example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + assertEquals(extension.metaData!!.version, "1.0") + + return GeckoResult.fromValue(AllowOrDeny.ALLOW) + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-with-perms-1.xpi")) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onUpdatePrompt(currentlyInstalled: WebExtension, + updatedExtension: WebExtension, + newPermissions: Array, + newOrigins: Array): GeckoResult { + assertEquals(currentlyInstalled.metaData!!.version, "1.0") + assertEquals(updatedExtension.metaData!!.version, "2.0") + return GeckoResult.fromValue(AllowOrDeny.DENY); + } + }) + + + sessionRule.waitForResult(controller.update(update1).accept({ + // We should not be able to update the extension. + assertTrue(false) + }, { exception -> + assertTrue(exception is WebExtension.InstallException) + val installException = exception as WebExtension.InstallException + assertEquals(installException.code, WebExtension.InstallException.ErrorCodes.ERROR_USER_CANCELED) + })); + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that updated extension changed the border color. + assertBodyBorderEqualTo("red") + + // Unregister WebExtension and check again + sessionRule.waitForResult(controller.uninstall(update1)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being unregistered + assertBodyBorderEqualTo("") + } } diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java index 36a22fecb99c..bdbdd4fd986c 100644 --- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java @@ -1135,6 +1135,8 @@ public class WebExtension { public static final int ERROR_UNEXPECTED_ADDON_TYPE = -6; /** The extension did not have the expected ID. */ public static final int ERROR_INCORRECT_ID = -7; + /** The extension install was canceled. */ + public static final int ERROR_USER_CANCELED = -100; /** For testing. */ protected ErrorCodes() {} @@ -1148,7 +1150,8 @@ public class WebExtension { ErrorCodes.ERROR_FILE_ACCESS, ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED, ErrorCodes.ERROR_UNEXPECTED_ADDON_TYPE, - ErrorCodes.ERROR_INCORRECT_ID + ErrorCodes.ERROR_INCORRECT_ID, + ErrorCodes.ERROR_USER_CANCELED, }) /* package */ @interface Codes {} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java index f2becca15a8d..b18421f66375 100644 --- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java @@ -22,6 +22,8 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import static org.mozilla.geckoview.WebExtension.InstallException.ErrorCodes.ERROR_USER_CANCELED; + public class WebExtensionController { private final static String LOGTAG = "WebExtension"; @@ -256,14 +258,31 @@ public class WebExtensionController { return null; } - /* - TODO: Bug 1599581 + /** + * Called whenever an updated extension has new permissions. This is intended as an + * opportunity for the app to prompt the user for the new permissions required by + * this extension. + * + * @param currentlyInstalled The {@link WebExtension} that is currently installed. + * @param updatedExtension The {@link WebExtension} that will replace the previous extension. + * @param newPermissions The new permissions that are needed. + * @param newOrigins The new origins that are needed. + * + * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} + * if this extension should be update or {@link AllowOrDeny#DENY DENY} if + * this extension should not be update. A null value will be interpreted as + * {@link AllowOrDeny#DENY DENY}. + */ + @Nullable default GeckoResult onUpdatePrompt( - WebExtension currentlyInstalled, - WebExtension updatedExtension, - String[] newPermissions) { + @NonNull WebExtension currentlyInstalled, + @NonNull WebExtension updatedExtension, + @NonNull String[] newPermissions, + @NonNull String[] newOrigins) { return null; } + + /* TODO: Bug 1601420 default GeckoResult onOptionalPrompt( WebExtension extension, @@ -311,6 +330,11 @@ public class WebExtensionController { private static class WebExtensionResult extends GeckoResult implements EventCallback { + /** These states should match gecko's AddonManager.STATE_* constants. */ + private static class StateCodes { + public static final int STATE_CANCELED = 12; + } + private final String mFieldName; public WebExtensionResult(final String fieldName) { @@ -319,6 +343,10 @@ public class WebExtensionController { @Override public void sendSuccess(final Object response) { + if (response == null) { + complete(null); + return; + } final GeckoBundle bundle = (GeckoBundle) response; complete(new WebExtension(bundle.getBundle(mFieldName))); } @@ -328,7 +356,11 @@ public class WebExtensionController { if (response instanceof GeckoBundle && ((GeckoBundle) response).containsKey("installError")) { final GeckoBundle bundle = (GeckoBundle) response; - final int errorCode = bundle.getInt("installError"); + int errorCode = bundle.getInt("installError"); + final int installState = bundle.getInt("state"); + if (errorCode == 0 && installState == StateCodes.STATE_CANCELED) { + errorCode = ERROR_USER_CANCELED; + } completeExceptionally(new WebExtension.InstallException(errorCode)); } else { completeExceptionally(new Exception(response.toString())); @@ -550,8 +582,31 @@ public class WebExtensionController { return result; } - // TODO: Bug 1599581 make public - GeckoResult update(final WebExtension extension) { + /** + * Update a web extension. + * + * When checking for an update, GeckoView will download the update manifest that is defined by the + * web extension's manifest property + * browser_specific_settings.gecko.update_url. + * If an update is found it will be downloaded and installed. If the extension needs any new + * permissions the {@link PromptDelegate#updatePrompt} will be triggered. + * + * More information about the update manifest format is available + * here. + * + * @param extension The extension to update. + * + * @return A {@link GeckoResult} that will complete when the update process finishes. If an + * update is found and installed successfully, the GeckoResult will return the updated + * {@link WebExtension}. If no update is available, null will be returned. If the updated + * extension requires new permissions, the {@link PromptDelegate#installPrompt} + * will be called. + * + * @see PromptDelegate#updatePrompt + */ + @AnyThread + @NonNull + public GeckoResult update(final @NonNull WebExtension extension) { final WebExtensionResult result = new WebExtensionResult("extension"); final GeckoBundle bundle = new GeckoBundle(1); @@ -561,7 +616,9 @@ public class WebExtensionController { bundle, result); return result.then(newExtension -> { - registerWebExtension(newExtension); + if (newExtension != null) { + registerWebExtension(newExtension); + } return GeckoResult.fromValue(newExtension); }); } @@ -603,6 +660,9 @@ public class WebExtensionController { } else if ("GeckoView:WebExtension:InstallPrompt".equals(event)) { installPrompt(bundle, callback); return; + } else if ("GeckoView:WebExtension:UpdatePrompt".equals(event)) { + updatePrompt(bundle, callback); + return; } final String nativeApp = bundle.getString("nativeApp"); @@ -667,12 +727,47 @@ public class WebExtensionController { } promptResponse.accept(allowOrDeny -> { - GeckoBundle response = new GeckoBundle(1); - if (AllowOrDeny.ALLOW.equals(allowOrDeny)) { - response.putBoolean("allow", true); - } else { - response.putBoolean("allow", false); + final GeckoBundle response = new GeckoBundle(1); + response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny)); + callback.sendSuccess(response); + }); + } + + private void updatePrompt(final GeckoBundle message, final EventCallback callback) { + final GeckoBundle currentBundle = message.getBundle("currentlyInstalled"); + final GeckoBundle updatedBundle = message.getBundle("updatedExtension"); + final String[] newPermissions = message.getStringArray("newPermissions"); + final String[] newOrigins = message.getStringArray("newOrigins"); + if (currentBundle == null || updatedBundle == null) { + if (BuildConfig.DEBUG) { + throw new RuntimeException("Missing bundle"); } + + Log.e(LOGTAG, "Missing bundle"); + return; + } + + final WebExtension currentExtension = new WebExtension(currentBundle); + currentExtension.setDelegateController(new DelegateController(currentExtension)); + + final WebExtension updatedExtension = new WebExtension(updatedBundle); + updatedExtension.setDelegateController(new DelegateController(updatedExtension)); + + if (mPromptDelegate == null) { + Log.e(LOGTAG, "Tried to update extension " + currentExtension.id + + " but no delegate is registered"); + return; + } + + final GeckoResult promptResponse = mPromptDelegate.onUpdatePrompt( + currentExtension, updatedExtension, newPermissions, newOrigins); + if (promptResponse == null) { + return; + } + + promptResponse.accept(allowOrDeny -> { + final GeckoBundle response = new GeckoBundle(1); + response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny)); callback.sendSuccess(response); }); } diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md index 78107047180c..d364481994f0 100644 --- a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md @@ -43,6 +43,7 @@ exclude: true - Added ['WebExtension.metaData.optionsUrl'][74.14] and ['WebExtension.metaData.openOptionsPageInTab'][74.15], which is the addon metadata necessary to show their option pages. ([bug 1598792]({{bugzilla}}1598792)) +- Added [`WebExtensionController.update`][74.16] to update extensions. ([bug 1599581]({{bugzilla}}1599581)) [74.1]: {{javadoc_uri}}/WebExtensionController.html#enable-org.mozilla.geckoview.WebExtension-int- [74.2]: {{javadoc_uri}}/WebExtensionController.html#disable-org.mozilla.geckoview.WebExtension-int- @@ -59,6 +60,7 @@ exclude: true [74.13]: {{javadoc_uri}}/WebExtensionController.html#setTabActive [74.14]: {{javadoc_uri}}/WebExtension.MetaData.html#optionsUrl [74.15]: {{javadoc_uri}}/WebExtension.MetaData.html#openOptionsPageInTab +[74.16]: {{javadoc_uri}}/WebExtensionController.html#update-org.mozilla.geckoview.WebExtension-int- ## v73 - Added [`WebExtensionController.install`][73.1] and [`uninstall`][73.2] to @@ -569,4 +571,4 @@ exclude: true [65.24]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport-android.content.Context-android.os.Bundle-java.lang.String- [65.25]: {{javadoc_uri}}/GeckoResult.html -[api-version]: e9de8b39fbe7f7542db5ea472731a696bba5c95c +[api-version]: e7913b3fec18f7edb75b81e6bebfb7860ab57c91 diff --git a/mobile/android/modules/geckoview/GeckoViewWebExtension.jsm b/mobile/android/modules/geckoview/GeckoViewWebExtension.jsm index 6cd594922b3f..447cf887ce6c 100644 --- a/mobile/android/modules/geckoview/GeckoViewWebExtension.jsm +++ b/mobile/android/modules/geckoview/GeckoViewWebExtension.jsm @@ -298,13 +298,13 @@ class ExtensionInstallListener { } onDownloadCancelled(aInstall) { - const { error: installError } = aInstall; - this.resolve({ installError }); + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); } onDownloadFailed(aInstall) { - const { error: installError } = aInstall; - this.resolve({ installError }); + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); } onDownloadEnded() { @@ -312,13 +312,13 @@ class ExtensionInstallListener { } onInstallCancelled(aInstall) { - const { error: installError } = aInstall; - this.resolve({ installError }); + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); } onInstallFailed(aInstall) { - const { error: installError } = aInstall; - this.resolve({ installError }); + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); } onInstallEnded(aInstall, aAddon) { @@ -406,6 +406,37 @@ class MobileWindowTracker extends EventEmitter { var mobileWindowTracker = new MobileWindowTracker(); +async function updatePromptHandler(aInfo) { + const oldPerms = aInfo.existingAddon.userPermissions; + if (!oldPerms) { + // Updating from a legacy add-on, let it proceed + return; + } + + const newPerms = aInfo.addon.userPermissions; + + const difference = Extension.comparePermissions(oldPerms, newPerms); + + // If there are no new permissions, just proceed + if (!difference.origins.length && !difference.permissions.length) { + return; + } + + const currentlyInstalled = exportExtension(aInfo.existingAddon, oldPerms); + const updatedExtension = exportExtension(aInfo.addon, newPerms); + const response = await EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:WebExtension:UpdatePrompt", + currentlyInstalled, + updatedExtension, + newPermissions: difference.permissions, + newOrigins: difference.origins, + }); + + if (!response.allow) { + throw new Error("Extension update rejected."); + } +} + var GeckoViewWebExtension = { async registerWebExtension(aId, aUri, allowContentMessaging, aCallback) { const params = { @@ -564,6 +595,39 @@ var GeckoViewWebExtension = { ); }, + /** + * @return A promise resolved with either an AddonInstall object if an update + * is available or null if no update is found. + */ + checkForUpdate(aAddon) { + return new Promise(resolve => { + const listener = { + onUpdateAvailable(aAddon, install) { + install.promptHandler = updatePromptHandler; + resolve(install); + }, + onNoUpdateAvailable() { + resolve(null); + }, + }; + aAddon.findUpdates(listener, AddonManager.UPDATE_WHEN_USER_REQUESTED); + }); + }, + + async updateWebExtension(aId) { + const extension = await this.extensionById(aId); + + const install = await this.checkForUpdate(extension); + if (!install) { + return null; + } + const promise = new Promise(resolve => { + install.addListener(new ExtensionInstallListener(resolve)); + }); + install.install(); + return promise; + }, + /* eslint-disable complexity */ async onEvent(aEvent, aData, aCallback) { debug`onEvent ${aEvent} ${aData}`; @@ -749,8 +813,18 @@ var GeckoViewWebExtension = { } case "GeckoView:WebExtension:Update": { - // TODO - aCallback.onError(`Not implemented`); + try { + const { webExtensionId } = aData; + const result = await this.updateWebExtension(webExtensionId); + if (result === null || result.extension) { + aCallback.onSuccess(result); + } else { + aCallback.onError(result); + } + } catch (ex) { + debug`Failed update ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } break; } } diff --git a/mobile/android/moz.build b/mobile/android/moz.build index a938140c8e15..d3c711c781ca 100644 --- a/mobile/android/moz.build +++ b/mobile/android/moz.build @@ -50,6 +50,7 @@ DIRS += [ 'modules', 'themes/core', 'themes/geckoview', + 'geckoview/src/androidTest/assets', 'app', 'fonts', ] @@ -61,6 +62,7 @@ TEST_DIRS += [ TEST_HARNESS_FILES.testing.mochitest.tests.junit += [ 'geckoview/src/androidTest/assets/www/hello.html', 'geckoview/src/androidTest/assets/www/simple_redirect.sjs', + 'geckoview/src/androidTest/assets/www/update_manifest.json', ] SPHINX_TREES['/mobile/android'] = 'docs'