Bug 1381992 - Add some reader mode support to the tabs API, r=mixedpuppy
authorBob Silverberg <bsilverberg@mozilla.com>
Fri, 08 Sep 2017 17:00:27 -0400
changeset 433674 d2c18cfef4e3242545de01c1c36675d4c833bb43
parent 433673 cc9d239ebed1e08744e2acc483c34d2a8b09e92a
child 433675 d4a29741c62807f145203dc5689cb74bfbd83daa
push id8114
push userjlorenzo@mozilla.com
push dateThu, 02 Nov 2017 16:33:21 +0000
treeherdermozilla-beta@73e0d89a540f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmixedpuppy
bugs1381992
milestone58.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1381992 - Add some reader mode support to the tabs API, r=mixedpuppy This adds two properties to the Tab object: - isArticle indicates whether the document in the tab is likely able to be rendered in reader mode. - isInReaderMode indicates if the document in the tab is being rendered in reader mode. It also adds a toggleReaderMode() which toggles a tab into and out of reader mode. There is also a new case in which tabs.onUpdated will fire. When the isArticle status of a tab changes, an onUpdated event will fire with data {isArticle: boolean}. MozReview-Commit-ID: AaAQ0V5qm2Z
browser/components/extensions/ext-browser.js
browser/components/extensions/ext-tabs.js
browser/components/extensions/schemas/tabs.json
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_tabs_readerMode.js
browser/components/extensions/test/mochitest/test_ext_all_apis.html
mobile/android/components/extensions/ext-utils.js
mobile/android/components/extensions/schemas/tabs.json
toolkit/components/extensions/ext-tabs-base.js
--- a/browser/components/extensions/ext-browser.js
+++ b/browser/components/extensions/ext-browser.js
@@ -15,16 +15,18 @@
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
                                   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 
 var {
   ExtensionError,
   defineLazyGetter,
 } = ExtensionUtils;
 
+const READER_MODE_PREFIX = "about:reader";
+
 let tabTracker;
 let windowTracker;
 
 // This function is pretty tightly tied to Extension.jsm.
 // Its job is to fill in the |tab| property of the sender.
 const getSender = (extension, target, sender) => {
   let tabId;
   if ("tabId" in sender) {
@@ -223,16 +225,18 @@ class TabTracker extends TabTrackerBase 
     this._handleWindowClose = this._handleWindowClose.bind(this);
 
     windowTracker.addListener("TabClose", this);
     windowTracker.addListener("TabOpen", this);
     windowTracker.addListener("TabSelect", this);
     windowTracker.addOpenListener(this._handleWindowOpen);
     windowTracker.addCloseListener(this._handleWindowClose);
 
+    Services.mm.addMessageListener("Reader:UpdateReaderButton", this);
+
     /* eslint-disable mozilla/balanced-listeners */
     this.on("tab-detached", this._handleTabDestroyed);
     this.on("tab-removed", this._handleTabDestroyed);
     /* eslint-enable mozilla/balanced-listeners */
   }
 
   getId(nativeTab) {
     if (this._tabs.has(nativeTab)) {
@@ -358,16 +362,31 @@ class TabTracker extends TabTrackerBase 
         Promise.resolve().then(() => {
           this.emitActivated(nativeTab);
         });
         break;
     }
   }
 
   /**
+   * @param {Object} message
+   *        The message to handle.
+   * @private
+   */
+  receiveMessage(message) {
+    switch (message.name) {
+      case "Reader:UpdateReaderButton":
+        if (message.data && message.data.isArticle !== undefined) {
+          this.emit("tab-isarticle", message);
+        }
+        break;
+    }
+  }
+
+  /**
    * A private method which is called whenever a new browser window is opened,
    * and dispatches the necessary events for it.
    *
    * @param {DOMWindow} window
    *        The window being opened.
    * @private
    */
   _handleWindowOpen(window) {
@@ -630,16 +649,24 @@ class Tab extends TabBase {
   get window() {
     return this.nativeTab.ownerGlobal;
   }
 
   get windowId() {
     return windowTracker.getId(this.window);
   }
 
+  get isArticle() {
+    return this.nativeTab.linkedBrowser.isArticle;
+  }
+
+  get isInReaderMode() {
+    return this.url && this.url.startsWith(READER_MODE_PREFIX);
+  }
+
   /**
    * Converts session store data to an object compatible with the return value
    * of the convert() method, representing that data.
    *
    * @param {Extension} extension
    *        The extension for which to convert the data.
    * @param {Object} tabData
    *        Session store data for a closed tab, as returned by
--- a/browser/components/extensions/ext-tabs.js
+++ b/browser/components/extensions/ext-tabs.js
@@ -12,16 +12,20 @@ XPCOMUtils.defineLazyGetter(this, "strBu
 
 XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
                                   "resource://gre/modules/PrivateBrowsingUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils",
                                   "resource://gre/modules/PromiseUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 
+var {
+  ExtensionError,
+} = ExtensionUtils;
+
 let tabListener = {
   tabReadyInitialized: false,
   tabReadyPromises: new WeakMap(),
   initializingTabs: new WeakSet(),
 
   initTabReady() {
     if (!this.tabReadyInitialized) {
       windowTracker.addListener("progress", this);
@@ -284,28 +288,39 @@ this.tabs = class extends ExtensionAPI {
               if (url) {
                 changed.url = url;
               }
 
               fireForTab(tabManager.wrapTab(tabElem), changed);
             }
           };
 
+          let isArticleChangeListener = (eventName, event) => {
+            let {gBrowser} = event.target.ownerGlobal;
+            let tab = tabManager.getWrapper(
+              gBrowser.getTabForBrowser(event.target));
+
+            fireForTab(tab, {isArticle: event.data.isArticle});
+          };
+
           windowTracker.addListener("status", statusListener);
           windowTracker.addListener("TabAttrModified", listener);
           windowTracker.addListener("TabPinned", listener);
           windowTracker.addListener("TabUnpinned", listener);
           windowTracker.addListener("TabBrowserInserted", listener);
 
+          tabTracker.on("tab-isarticle", isArticleChangeListener);
+
           return () => {
             windowTracker.removeListener("status", statusListener);
             windowTracker.removeListener("TabAttrModified", listener);
             windowTracker.removeListener("TabPinned", listener);
             windowTracker.removeListener("TabUnpinned", listener);
             windowTracker.removeListener("TabBrowserInserted", listener);
+            tabTracker.off("tab-isarticle", isArticleChangeListener);
           };
         }).api(),
 
         create(createProperties) {
           return new Promise((resolve, reject) => {
             let window = createProperties.windowId !== null ?
               windowTracker.getWindow(createProperties.windowId, context) :
               windowTracker.topWindow;
@@ -908,13 +923,23 @@ this.tabs = class extends ExtensionAPI {
                 resolve(retval == 0 ? "saved" : "replaced");
               } else {
                 // Cancel clicked (retval == 1)
                 resolve("canceled");
               }
             });
           });
         },
+
+        async toggleReaderMode(tabId) {
+          let tab = await promiseTabWhenReady(tabId);
+          if (!tab.isInReaderMode && !tab.isArticle) {
+            throw new ExtensionError("The specified tab cannot be placed into reader mode.");
+          }
+          tab = getTabOrActive(tabId);
+
+          tab.linkedBrowser.messageManager.sendAsyncMessage("Reader:ToggleReaderMode");
+        },
       },
     };
     return self;
   }
 };
--- a/browser/components/extensions/schemas/tabs.json
+++ b/browser/components/extensions/schemas/tabs.json
@@ -71,17 +71,19 @@
           "title": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The title of the tab. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
           "favIconUrl": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission. It may also be an empty string if the tab is loading."},
           "status": {"type": "string", "optional": true, "description": "Either <em>loading</em> or <em>complete</em>."},
           "discarded": {"type": "boolean", "optional": true, "description": "True while the tab is not loaded with content."},
           "incognito": {"type": "boolean", "description": "Whether the tab is in an incognito window."},
           "width": {"type": "integer", "optional": true, "description": "The width of the tab in pixels."},
           "height": {"type": "integer", "optional": true, "description": "The height of the tab in pixels."},
           "sessionId": {"type": "string", "optional": true, "description": "The session ID used to uniquely identify a Tab obtained from the $(ref:sessions) API."},
-          "cookieStoreId": {"type": "string", "optional": true, "description": "The CookieStoreId used for the tab."}
+          "cookieStoreId": {"type": "string", "optional": true, "description": "The CookieStoreId used for the tab."},
+          "isArticle": {"type": "boolean", "optional": true, "description": "Whether the document in the tab can be rendered in reader mode."},
+          "isInReaderMode": {"type": "boolean", "optional": true, "description": "Whether the document in the tab is being rendered in reader mode."}
         }
       },
       {
         "id": "ZoomSettingsMode",
         "type": "string",
         "description": "Defines how zoom changes are handled, i.e. which entity is responsible for the actual scaling of the page; defaults to <code>automatic</code>.",
         "enum": [
           {
@@ -896,16 +898,31 @@
                 "name": "language",
                 "description": "An ISO language code such as <code>en</code> or <code>fr</code>. For a complete list of languages supported by this method, see <a href='http://src.chromium.org/viewvc/chrome/trunk/src/third_party/cld/languages/internal/languages.cc'>kLanguageInfoTable</a>. The 2nd to 4th columns will be checked and the first non-NULL value will be returned except for Simplified Chinese for which zh-CN will be returned. For an unknown language, <code>und</code> will be returned."
               }
             ]
           }
         ]
       },
       {
+        "name": "toggleReaderMode",
+        "type": "function",
+        "description": "Toggles reader mode for the document in the tab.",
+        "async": true,
+        "parameters": [
+          {
+            "type": "integer",
+            "name": "tabId",
+            "minimum": 0,
+            "optional": true,
+            "description": "Defaults to the active tab of the $(topic:current-window)[current window]."
+          }
+        ]
+      },
+      {
         "name": "captureVisibleTab",
         "type": "function",
         "description": "Captures the visible area of the currently active tab in the specified window. You must have $(topic:declare_permissions)[&lt;all_urls&gt;] permission to use this method.",
         "permissions": ["<all_urls>"],
         "async": "callback",
         "parameters": [
           {
             "type": "integer",
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -27,16 +27,18 @@ support-files =
   locale/chrome.manifest
   webNav_createdTarget.html
   webNav_createdTargetSource.html
   webNav_createdTargetSource_subframe.html
   serviceWorker.js
   searchSuggestionEngine.xml
   searchSuggestionEngine.sjs
   ../../../../../toolkit/components/extensions/test/mochitest/head_webrequest.js
+  ../../../../../toolkit/components/reader/test/readerModeNonArticle.html
+  ../../../../../toolkit/components/reader/test/readerModeArticle.html
 
 [browser_ext_browserAction_area.js]
 [browser_ext_browserAction_context.js]
 [browser_ext_browserAction_contextMenu.js]
 # bug 1369197
 skip-if = os == 'linux'
 [browser_ext_browserAction_disabled.js]
 [browser_ext_browserAction_pageAction_icon.js]
@@ -148,16 +150,17 @@ skip-if = os == "win" # Bug 1398514
 [browser_ext_tabs_move_window.js]
 [browser_ext_tabs_move_window_multiple.js]
 [browser_ext_tabs_move_window_pinned.js]
 [browser_ext_tabs_onHighlighted.js]
 [browser_ext_tabs_onUpdated.js]
 [browser_ext_tabs_opener.js]
 [browser_ext_tabs_printPreview.js]
 [browser_ext_tabs_query.js]
+[browser_ext_tabs_readerMode.js]
 [browser_ext_tabs_reload.js]
 [browser_ext_tabs_reload_bypass_cache.js]
 [browser_ext_tabs_sendMessage.js]
 [browser_ext_tabs_cookieStoreId.js]
 [browser_ext_tabs_update.js]
 [browser_ext_tabs_zoom.js]
 [browser_ext_tabs_update_url.js]
 [browser_ext_themes_icons.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_tabs_readerMode.js
@@ -0,0 +1,103 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_task(async function test_reader_mode() {
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      "permissions": ["tabs"],
+    },
+
+    async background() {
+      let tab;
+      let tabId;
+      let expected = {isInReaderMode: false};
+      let testState = {};
+      browser.test.onMessage.addListener(async (msg, ...args) => {
+        switch (msg) {
+          case "updateUrl":
+            expected.isArticle = args[0];
+            expected.url = args[1];
+            tab = await browser.tabs.update({url: expected.url});
+            tabId = tab.id;
+            break;
+          case "enterReaderMode":
+            expected.isArticle = !args[0];
+            expected.isInReaderMode = true;
+            tab = await browser.tabs.get(tabId);
+            browser.test.assertEq(false, tab.isInReaderMode, "The tab is not in reader mode.");
+            if (args[0]) {
+              browser.tabs.toggleReaderMode(tabId);
+            } else {
+              await browser.test.assertRejects(
+                browser.tabs.toggleReaderMode(tabId),
+                /The specified tab cannot be placed into reader mode/,
+                "Toggle fails with an unreaderable document.");
+              browser.test.assertEq(false, tab.isInReaderMode, "The tab is still not in reader mode.");
+              browser.test.sendMessage("enterFailed");
+            }
+            break;
+          case "leaveReaderMode":
+            expected.isInReaderMode = false;
+            tab = await browser.tabs.get(tabId);
+            browser.test.assertTrue(tab.isInReaderMode, "The tab is in reader mode.");
+            browser.tabs.toggleReaderMode(tabId);
+            break;
+        }
+      });
+
+      browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
+        if (tab.url !== "about:blank") {
+          if (changeInfo.status === "complete") {
+            testState.url = tab.url;
+            let urlOk = expected.isInReaderMode
+              ? testState.url.startsWith("about:reader")
+              : expected.url == testState.url;
+            if (urlOk && expected.isArticle == testState.isArticle) {
+              browser.test.sendMessage("tabUpdated", tab);
+            }
+            return;
+          }
+          if (changeInfo.isArticle == expected.isArticle
+              && changeInfo.isArticle != testState.isArticle) {
+            testState.isArticle = changeInfo.isArticle;
+            let urlOk = expected.isInReaderMode
+              ? testState.url.startsWith("about:reader")
+              : expected.url == testState.url;
+            if (urlOk && expected.isArticle == testState.isArticle) {
+              browser.test.sendMessage("isArticle", tab);
+            }
+          }
+        }
+      });
+    },
+  });
+
+  const TEST_PATH = getRootDirectory(gTestPath).replace("chrome://mochitests/content", "http://example.com");
+  const READER_MODE_PREFIX = "about:reader";
+
+  await extension.startup();
+  extension.sendMessage("updateUrl", true, `${TEST_PATH}readerModeArticle.html`);
+  let tab = await extension.awaitMessage("isArticle");
+
+  ok(!tab.url.startsWith(READER_MODE_PREFIX), "Tab url does not indicate reader mode.");
+  ok(tab.isArticle, "Tab is readerable.");
+
+  extension.sendMessage("enterReaderMode", true);
+  tab = await extension.awaitMessage("tabUpdated");
+  ok(tab.url.startsWith(READER_MODE_PREFIX), "Tab url indicates reader mode.");
+
+  extension.sendMessage("leaveReaderMode");
+  tab = await extension.awaitMessage("tabUpdated");
+  ok(!tab.url.startsWith(READER_MODE_PREFIX), "Tab url does not indicate reader mode.");
+
+  extension.sendMessage("updateUrl", false, `${TEST_PATH}readerModeNonArticle.html`);
+  tab = await extension.awaitMessage("tabUpdated");
+  ok(!tab.url.startsWith(READER_MODE_PREFIX), "Tab url does not indicate reader mode.");
+  ok(!tab.isArticle, "Tab is not readerable.");
+
+  extension.sendMessage("enterReaderMode", false);
+  await extension.awaitMessage("enterFailed");
+
+  await extension.unload();
+});
--- a/browser/components/extensions/test/mochitest/test_ext_all_apis.html
+++ b/browser/components/extensions/test/mochitest/test_ext_all_apis.html
@@ -48,16 +48,17 @@ let expectedBackgroundApisTargetSpecific
   "tabs.query",
   "tabs.reload",
   "tabs.remove",
   "tabs.removeCSS",
   "tabs.saveAsPDF",
   "tabs.sendMessage",
   "tabs.setZoom",
   "tabs.setZoomSettings",
+  "tabs.toggleReaderMode",
   "tabs.update",
   "windows.CreateType",
   "windows.WINDOW_ID_CURRENT",
   "windows.WINDOW_ID_NONE",
   "windows.WindowState",
   "windows.WindowType",
   "windows.create",
   "windows.get",
--- a/mobile/android/components/extensions/ext-utils.js
+++ b/mobile/android/components/extensions/ext-utils.js
@@ -552,16 +552,26 @@ class Tab extends TabBase {
 
   get window() {
     return this.browser.ownerGlobal;
   }
 
   get windowId() {
     return windowTracker.getId(this.window);
   }
+
+  // TODO: Just return false for these until properly implemented on Android.
+  // https://bugzilla.mozilla.org/show_bug.cgi?id=1402924
+  get isArticle() {
+    return false;
+  }
+
+  get isInReaderMode() {
+    return false;
+  }
 }
 
 // Manages tab-specific context data and dispatches tab select and close events.
 class TabContext extends EventEmitter {
   constructor(getDefaults, extension) {
     super();
 
     this.extension = extension;
--- a/mobile/android/components/extensions/schemas/tabs.json
+++ b/mobile/android/components/extensions/schemas/tabs.json
@@ -71,17 +71,19 @@
           "title": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The title of the tab. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."},
           "favIconUrl": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission. It may also be an empty string if the tab is loading."},
           "status": {"type": "string", "optional": true, "description": "Either <em>loading</em> or <em>complete</em>."},
           "discarded": {"type": "boolean", "optional": true, "description": "True while the tab is not loaded with content."},
           "incognito": {"type": "boolean", "description": "Whether the tab is in an incognito window."},
           "width": {"type": "integer", "optional": true, "description": "The width of the tab in pixels."},
           "height": {"type": "integer", "optional": true, "description": "The height of the tab in pixels."},
           "sessionId": {"type": "string", "optional": true, "description": "The session ID used to uniquely identify a Tab obtained from the $(ref:sessions) API."},
-          "cookieStoreId": {"type": "string", "optional": true, "description": "The CookieStoreId used for the tab."}
+          "cookieStoreId": {"type": "string", "optional": true, "description": "The CookieStoreId used for the tab."},
+          "isArticle": {"type": "boolean", "optional": true, "description": "Whether the document in the tab can be rendered in reader mode."},
+          "isInReaderMode": {"type": "boolean", "optional": true, "description": "Whether the document in the tab is being rendered in reader mode."}
         }
       },
       {
         "id": "ZoomSettingsMode",
         "type": "string",
         "description": "Defines how zoom changes are handled, i.e. which entity is responsible for the actual scaling of the page; defaults to <code>automatic</code>.",
         "enum": [
           {
--- a/toolkit/components/extensions/ext-tabs-base.js
+++ b/toolkit/components/extensions/ext-tabs-base.js
@@ -415,16 +415,38 @@ class TabBase {
    *        @readonly
    *        @abstract
    */
   get windowId() {
     throw new Error("Not implemented");
   }
 
   /**
+   * @property {boolean} isArticle
+   *        Returns true if the document in the tab can be rendered in reader
+   *        mode.
+   *        @readonly
+   *        @abstract
+   */
+  get isArticle() {
+    throw new Error("Not implemented");
+  }
+
+  /**
+   * @property {boolean} isInReaderMode
+   *        Returns true if the document in the tab is being rendered in reader
+   *        mode.
+   *        @readonly
+   *        @abstract
+   */
+  get isInReaderMode() {
+    throw new Error("Not implemented");
+  }
+
+  /**
    * Returns true if this tab matches the the given query info object. Omitted
    * or null have no effect on the match.
    *
    * @param {object} queryInfo
    *        The query info against which to match.
    * @param {boolean} [queryInfo.active]
    *        Matches against the exact value of the tab's `active` attribute.
    * @param {boolean} [queryInfo.audible]
@@ -493,16 +515,18 @@ class TabBase {
       status: this.status,
       discarded: this.discarded,
       incognito: this.incognito,
       width: this.width,
       height: this.height,
       lastAccessed: this.lastAccessed,
       audible: this.audible,
       mutedInfo: this.mutedInfo,
+      isArticle: this.isArticle,
+      isInReaderMode: this.isInReaderMode,
     };
 
     // If the tab has not been fully layed-out yet, fallback to the geometry
     // from a different tab (usually the currently active tab).
     if (fallbackTab && (!result.width || !result.height)) {
       result.width = fallbackTab.width;
       result.height = fallbackTab.height;
     }