Bug 1391579 Part 4: Handle the in-app extension update flow for Fennec draft
authorAndrew Swan <aswan@mozilla.com>
Fri, 08 Sep 2017 16:48:22 -0700
changeset 664044 3ad0644075f5679e14d3d660cb82af9c11e0ecfc
parent 664043 9ab93fa2081802e37faa1ae1af69c52d99b74fe5
child 731355 19d81e90771df60bb18fb63f38b4ccd1613bb02a
push id79604
push useraswan@mozilla.com
push dateWed, 13 Sep 2017 17:14:38 +0000
bugs1391579
milestone57.0a1
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
mobile/android/chrome/content/ExtensionPermissions.js
mobile/android/chrome/content/aboutAddons.js
mobile/android/chrome/content/aboutAddons.xhtml
mobile/android/locales/en-US/chrome/aboutAddons.dtd
mobile/android/locales/en-US/chrome/browser.properties
mobile/android/themes/core/aboutAddons.css
mobile/android/themes/core/images/extension-update.svg
mobile/android/themes/core/jar.mn
--- 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)