Bug 1391579 Part 4: Handle the in-app extension update flow for Fennec
The mechanics implemented here involve listening for extension updates that
require new permissions, notifying the user with icons attached to the
top level Add-ons menu and to the individual item in about:addons, and
then showing the permissions dialog when the user asks to update.
The basic plumbing is mostly in ExtensionPermissions.js, this also
required a fair amount of change to aboutAddons to accomodate new UI
elements, and to handle updates gracefully.
MozReview-Commit-ID: Jkgc3OVYtnc
--- a/mobile/android/chrome/content/ExtensionPermissions.js
+++ b/mobile/android/chrome/content/ExtensionPermissions.js
@@ -1,14 +1,17 @@
"use strict";
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionData",
"resource://gre/modules/Extension.jsm");
var ExtensionPermissions = {
+ // id -> object containing update details (see applyUpdate() )
+ updates: new Map(),
+
// Prepare the strings needed for a permission notification.
_prepareStrings(info) {
let appName = Strings.brand.GetStringFromName("brandShortName");
let info2 = Object.assign({appName, addonName: info.addon.name}, info);
let strings = ExtensionData.formatPermissionStrings(info2, Strings.browser);
// We dump the main body of the dialog into a big android
// TextView. Build a big string with the full contents here.
@@ -50,19 +53,69 @@ var ExtensionPermissions = {
info.resolve();
} else {
info.reject();
}
break;
}
case "webextension-update-permissions":
- // To be implemented in bug 1391579, just auto-approve until then
- subject.wrappedJSObject.resolve();
+ let info = subject.wrappedJSObject;
+ let {addon, resolve, reject} = info;
+ let stringInfo = Object.assign({
+ type: "update",
+ }, info);
+
+ let details = this._prepareStrings(stringInfo);
+
+ // If there are no promptable permissions, just apply the update
+ if (details.message.length == 0) {
+ resolve();
+ return;
+ }
+
+ // Store all the details about the update until the user chooses to
+ // look at update, at which point we will pick up in this.applyUpdate()
+ details.icon = this._prepareIcon(addon.iconURL || "dummy.svg");
+
+ let first = (this.updates.size == 0);
+ this.updates.set(addon.id, {details, resolve, reject});
+
+ if (first) {
+ EventDispatcher.instance.sendRequest({
+ type: "Extension:ShowUpdateIcon",
+ value: true,
+ });
+ }
break;
case "webextension-optional-permission-prompt":
// To be implemented in bug 1392176, just auto-approve until then
subject.wrappedJSObject.resolve(true);
break;
}
},
+
+ async applyUpdate(id) {
+ if (!this.updates.has(id)) {
+ return;
+ }
+
+ let update = this.updates.get(id);
+ this.updates.delete(id);
+ if (this.updates.size == 0) {
+ EventDispatcher.instance.sendRequest({
+ type: "Extension:ShowUpdateIcon",
+ value: false,
+ });
+ }
+
+ let {details} = update;
+ details.type = "Extension:PermissionPrompt";
+
+ let accepted = await EventDispatcher.instance.sendRequestForResult(details);
+ if (accepted) {
+ update.resolve();
+ } else {
+ update.reject();
+ }
+ },
};
--- a/mobile/android/chrome/content/aboutAddons.js
+++ b/mobile/android/chrome/content/aboutAddons.js
@@ -8,16 +8,17 @@
var Ci = Components.interfaces, Cc = Components.classes, Cu = Components.utils;
Cu.import("resource://gre/modules/Services.jsm")
Cu.import("resource://gre/modules/AddonManager.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
const AMO_ICON = "chrome://browser/skin/images/amo-logo.png";
+const UPDATE_INDICATOR = "chrome://browser/skin/images/extension-update.svg";
var gStringBundle = Services.strings.createBundle("chrome://browser/locale/aboutAddons.properties");
XPCOMUtils.defineLazyGetter(window, "gChromeWin", function() {
return window.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIWebNavigation)
.QueryInterface(Ci.nsIDocShellTreeItem)
.rootTreeItem
@@ -230,16 +231,22 @@ var Addons = {
if ("description" in aAddon) {
let descPart = document.createElement("div");
descPart.textContent = aAddon.description;
descPart.className = "description";
inner.appendChild(descPart);
}
outer.appendChild(inner);
+
+ let update = document.createElement("img");
+ update.className = "update-indicator";
+ update.setAttribute("src", UPDATE_INDICATOR);
+ outer.appendChild(update);
+
return outer;
},
_createBrowseItem: function _createBrowseItem() {
let outer = document.createElement("div");
outer.className = "addon-item list-item";
outer.setAttribute("role", "button");
outer.addEventListener("click", function(event) {
@@ -265,20 +272,18 @@ var Addons = {
title.textContent = gStringBundle.GetStringFromName("addons.browseAll");
inner.appendChild(title);
outer.appendChild(inner);
return outer;
},
_createItemForAddon: function _createItemForAddon(aAddon) {
- let appManaged = (aAddon.scope == AddonManager.SCOPE_APPLICATION);
let opType = this._getOpTypeForOperations(aAddon.pendingOperations);
- let updateable = (aAddon.permissions & AddonManager.PERM_CAN_UPGRADE) > 0;
- let uninstallable = (aAddon.permissions & AddonManager.PERM_CAN_UNINSTALL) > 0;
+ let hasUpdate = this._addonHasUpdate(aAddon);
let optionsURL = aAddon.optionsURL || "";
let blocked = "";
switch (aAddon.blocklistState) {
case Ci.nsIBlocklistService.STATE_BLOCKED:
blocked = "blocked";
break;
@@ -289,31 +294,35 @@ var Addons = {
blocked = "outdated";
break;
}
let item = this._createItem(aAddon);
item.setAttribute("isDisabled", !aAddon.isActive);
item.setAttribute("isUnsigned", aAddon.signedState <= AddonManager.SIGNEDSTATE_MISSING);
item.setAttribute("opType", opType);
- item.setAttribute("updateable", updateable);
if (blocked)
item.setAttribute("blockedStatus", blocked);
item.setAttribute("optionsURL", optionsURL);
+ item.setAttribute("hasUpdate", hasUpdate);
item.addon = aAddon;
return item;
},
_getElementForAddon: function(aKey) {
let list = document.getElementById("addons-list");
let element = list.querySelector("div[addonID=\"" + CSS.escape(aKey) + "\"]");
return element;
},
+ _addonHasUpdate(addon) {
+ return gChromeWin.ExtensionPermissions.updates.has(addon.id);
+ },
+
init: async function init() {
const aAddons = await AddonManager.getAllAddons();
// Clear all content before filling the addons
let list = document.getElementById("addons-list");
list.innerHTML = "";
aAddons.sort(function(a, b) {
@@ -328,16 +337,17 @@ var Addons = {
let item = this._createItemForAddon(aAddons[i]);
list.appendChild(item);
}
// Add a "Browse all Firefox Add-ons" item to the bottom of the list.
let browseItem = this._createBrowseItem();
list.appendChild(browseItem);
+ document.getElementById("update-btn").addEventListener("click", Addons.updateCurrent.bind(this));
document.getElementById("uninstall-btn").addEventListener("click", Addons.uninstallCurrent.bind(this));
document.getElementById("cancel-btn").addEventListener("click", Addons.cancelUninstall.bind(this));
document.getElementById("disable-btn").addEventListener("click", Addons.disable.bind(this));
document.getElementById("enable-btn").addEventListener("click", Addons.enable.bind(this));
document.getElementById("unsigned-learn-more").addEventListener("click", function() {
openLink(Services.urlFormatter.formatURLPref("app.support.baseURL") + "unsigned-addons");
});
@@ -365,16 +375,23 @@ var Addons = {
favicon.setAttribute("src", addon.iconURL || AMO_ICON);
detailItem.querySelector(".title").textContent = addon.name;
detailItem.querySelector(".version").textContent = addon.version;
detailItem.querySelector(".description-full").textContent = addon.description;
detailItem.querySelector(".status-uninstalled").textContent =
gStringBundle.formatStringFromName("addonStatus.uninstalled", [addon.name], 1);
+ let updateBtn = document.getElementById("update-btn");
+ if (this._addonHasUpdate(addon)) {
+ updateBtn.removeAttribute("hidden");
+ } else {
+ updateBtn.setAttribute("hidden", true);
+ }
+
let enableBtn = document.getElementById("enable-btn");
if (addon.appDisabled) {
enableBtn.setAttribute("disabled", "true");
} else {
enableBtn.removeAttribute("disabled");
}
let uninstallBtn = document.getElementById("uninstall-btn");
@@ -626,16 +643,26 @@ var Addons = {
enable: function enable() {
this.setEnabled(true);
},
disable: function disable() {
this.setEnabled(false);
},
+ updateCurrent() {
+ let detailItem = document.querySelector("#addons-details > .addon-item");
+
+ let addon = detailItem.addon;
+ if (!addon)
+ return;
+
+ gChromeWin.ExtensionPermissions.applyUpdate(addon.id);
+ },
+
uninstallCurrent: function uninstallCurrent() {
let detailItem = document.querySelector("#addons-details > .addon-item");
let addon = detailItem.addon;
if (!addon)
return;
this.uninstall(addon);
@@ -718,17 +745,35 @@ var Addons = {
if (needsRestart)
element.setAttribute("opType", "needs-restart");
},
onInstalled: function(aAddon) {
let list = document.getElementById("addons-list");
let element = this._getElementForAddon(aAddon.id);
- if (!element) {
+ if (element) {
+ // Upgrade of an existing addon, update version and description in
+ // list item and detail view, plus indicators about a pending update.
+ element.querySelector(".version").textContent = aAddon.version;
+
+ let desc = element.querySelector(".description");
+ if (desc) {
+ desc.textContent = aAddon.description;
+ }
+
+ element.setAttribute("hasUpdate", false);
+ document.getElementById("update-btn").setAttribute("hidden", true);
+
+ element = document.querySelector("#addons-details > .addon-item");
+ if (element.addon && element.addon.id == aAddon.id) {
+ element.querySelector(".version").textContent = aAddon.version;
+ element.querySelector(".description-full").textContent = aAddon.description;
+ }
+ } else {
element = this._createItemForAddon(aAddon);
// Themes aren't considered active on install, so set existing as disabled, and new one enabled.
if (aAddon.type == "theme") {
let item = list.firstElementChild;
while (item) {
if (item.addon && (item.addon.type == "theme")) {
item.setAttribute("isDisabled", true);
--- a/mobile/android/chrome/content/aboutAddons.xhtml
+++ b/mobile/android/chrome/content/aboutAddons.xhtml
@@ -44,16 +44,17 @@
<div class="title"></div><div class="version"></div>
</div>
<div class="description-full"></div>
</div>
<div class="warn-unsigned">&addonUnsigned.message; <a id="unsigned-learn-more">&addonUnsigned.learnMore;</a></div>
<div class="options-box"></div>
<div class="status status-uninstalled show-on-uninstall"></div>
<div class="buttons">
+ <button id="update-btn" class="show-on-update">&addonAction.update;</button>
<button id="enable-btn" class="show-on-disable hide-on-enable hide-on-uninstall" >&addonAction.enable;</button>
<button id="disable-btn" class="show-on-enable hide-on-disable hide-on-uninstall" >&addonAction.disable;</button>
<button id="uninstall-btn" class="hide-on-uninstall" >&addonAction.uninstall;</button>
<button id="cancel-btn" class="show-on-uninstall" >&addonAction.undo;</button>
</div>
</div>
</div>
--- a/mobile/android/locales/en-US/chrome/aboutAddons.dtd
+++ b/mobile/android/locales/en-US/chrome/aboutAddons.dtd
@@ -4,11 +4,12 @@
<!ENTITY aboutAddons.title2 "Add-ons">
<!ENTITY aboutAddons.header2 "Your Add-ons">
<!ENTITY addonAction.enable "Enable">
<!ENTITY addonAction.disable "Disable">
<!ENTITY addonAction.uninstall "Uninstall">
<!ENTITY addonAction.undo "Undo">
+<!ENTITY addonAction.update "Update">
<!ENTITY addonUnsigned.message "This add-on could not be verified by &brandShortName;.">
<!ENTITY addonUnsigned.learnMore "Learn more">
--- a/mobile/android/locales/en-US/chrome/browser.properties
+++ b/mobile/android/locales/en-US/chrome/browser.properties
@@ -121,16 +121,23 @@ webextPerms.header=Add %S?
# This string will be followed by a list of permissions requested
# by the webextension.
webextPerms.listIntro=It requires your permission to:
webextPerms.add.label=Add
webextPerms.add.accessKey=A
webextPerms.cancel.label=Cancel
webextPerms.cancel.accessKey=C
+# LOCALIZATION NOTE (webextPerms.updateText)
+# %S is replaced with the localized name of the updated extension.
+webextPerms.updateText=%S has been updated. You must approve new permissions before the updated version will install. Choosing “Cancel” will maintain your current add-on version.
+
+webextPerms.updateAccept.label=Update
+webextPerms.updateAccept.accessKey=U
+
webextPerms.description.bookmarks=Read and modify bookmarks
webextPerms.description.browserSettings=Read and modify browser settings
webextPerms.description.clipboardRead=Get data from the clipboard
webextPerms.description.clipboardWrite=Input data to the clipboard
webextPerms.description.downloads=Download files and read and modify the browser’s download history
webextPerms.description.geolocation=Access your location
webextPerms.description.history=Access browsing history
webextPerms.description.management=Monitor extension usage and manage themes
--- a/mobile/android/themes/core/aboutAddons.css
+++ b/mobile/android/themes/core/aboutAddons.css
@@ -74,30 +74,41 @@ a:active {
.addon-item[isDisabled="true"] .options-header,
.addon-item[optionsURL=""] .options-header,
.addon-item[isDisabled="true"] .options-box,
.addon-item[optionsURL=""] .options-box {
display: none;
}
+.update-indicator {
+ position: absolute;
+ right: var(--icon-margin);
+ top: var(--icon-margin);
+ width: var(--icon-size);
+ height: var(--icon-size);
+}
+
+.addon-item[hasUpdate="false"] > .update-indicator {
+ display: none;
+}
+
#addons-details > .list-item {
margin-bottom: 42px;
border-bottom: none;
}
#addons-details > .list-item:active {
background-color: #fff;
}
/* Buttons */
.buttons {
- display: flex;
- flex-direction: row;
+ display: grid;
width: 100%;
position: fixed;
bottom: 0px;
}
.buttons::after {
content: "";
border-right: 1px solid var(--color_about_item_border);
@@ -106,21 +117,29 @@ a:active {
.buttons > button {
-moz-appearance: none;
font-size: 1em;
border: 1px solid transparent;
border-right: none;
border-top-color: var(--color_about_item_border);
border-inline-start-color: var(--color_about_item_border);
background-color: var(--color_about_item);
- flex: 1;
padding: 0.75em 0.5em;
border-radius: 0;
}
+button#update-btn {
+ grid-row: 1;
+ grid-column: span 2;
+}
+
+button:not(#update-btn) {
+ grid-row: 2;
+}
+
.buttons > button:active {
background-color: #eeeeee;
}
.buttons > button[disabled="true"] {
color: #b5b5b5;
}
new file mode 100644
--- /dev/null
+++ b/mobile/android/themes/core/images/extension-update.svg
@@ -0,0 +1,7 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <rect width="16" height="16" fill="#00FEFF" rx="8"/>
+ <path stroke="#008EA4" stroke-linecap="round" stroke-width="2" d="M8,2 L8,14 M8,2 L4,6 M8,2 L12,6"/>
+</svg>
--- a/mobile/android/themes/core/jar.mn
+++ b/mobile/android/themes/core/jar.mn
@@ -68,8 +68,9 @@ chrome.jar:
skin/images/reader-plus-xhdpi.png (images/reader-plus-xhdpi.png)
skin/images/reader-plus-xxhdpi.png (images/reader-plus-xxhdpi.png)
skin/images/reader-style-icon-hdpi.png (images/reader-style-icon-hdpi.png)
skin/images/reader-style-icon-xhdpi.png (images/reader-style-icon-xhdpi.png)
skin/images/reader-style-icon-xxhdpi.png (images/reader-style-icon-xxhdpi.png)
skin/images/privatebrowsing-mask.png (images/privatebrowsing-mask.png)
skin/images/privatebrowsing-mask-and-shield.svg (images/privatebrowsing-mask-and-shield.svg)
skin/images/icon_key_emptypage.svg (images/icon_key_emptypage.svg)
+ skin/images/extension-update.svg (images/extension-update.svg)