Bug 1299775 - Implement chrome.idle.onStateChanged, r?aswan draft
authorBob Silverberg <bsilverberg@mozilla.com>
Mon, 19 Sep 2016 15:44:09 -0400
changeset 416633 d280cae16c8e551c96ed395bef3cb986d21d9ee1
parent 416562 f0e6cc6360213ba21fd98c887b55fce5c680df68
child 531901 5455bfa5a1eaf25c8e4158b5a2961d96d6d5778b
push id30195
push userbmo:bob.silverberg@gmail.com
push dateThu, 22 Sep 2016 17:12:48 +0000
reviewersaswan
bugs1299775
milestone52.0a1
Bug 1299775 - Implement chrome.idle.onStateChanged, r?aswan MozReview-Commit-ID: 2M5prJYDdqe
toolkit/components/extensions/ext-idle.js
toolkit/components/extensions/schemas/idle.json
toolkit/components/extensions/test/mochitest/chrome.ini
toolkit/components/extensions/test/mochitest/test_chrome_ext_idle.html
toolkit/components/extensions/test/xpcshell/test_ext_idle.js
--- a/toolkit/components/extensions/ext-idle.js
+++ b/toolkit/components/extensions/ext-idle.js
@@ -1,18 +1,94 @@
 "use strict";
 
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
+                                  "resource://devtools/shared/event-emitter.js");
 XPCOMUtils.defineLazyServiceGetter(this, "idleService",
                                    "@mozilla.org/widget/idleservice;1",
                                    "nsIIdleService");
+const {
+  SingletonEventManager,
+} = ExtensionUtils;
+
+// WeakMap[Extension -> Object]
+var observersMap = new WeakMap();
+
+function getObserverInfo(extension, context) {
+  let observerInfo = observersMap.get(extension);
+  if (!observerInfo) {
+    observerInfo = {
+      observer: null,
+      detectionInterval: 60,
+    };
+    observersMap.set(extension, observerInfo);
+    context.callOnClose({
+      close: () => {
+        let {observer, detectionInterval} = observersMap.get(extension);
+        if (observer) {
+          idleService.removeIdleObserver(observer, detectionInterval);
+        }
+        observersMap.delete(extension);
+      },
+    });
+  }
+  return observerInfo;
+}
+
+function getObserver(extension, context) {
+  let observerInfo = getObserverInfo(extension, context);
+  let {observer, detectionInterval} = observerInfo;
+  if (!observer) {
+    observer = {
+      observe: function(subject, topic, data) {
+        if (topic == "idle" || topic == "active") {
+          this.emit("stateChanged", topic);
+        }
+      },
+    };
+    EventEmitter.decorate(observer);
+    idleService.addIdleObserver(observer, detectionInterval);
+    observerInfo.observer = observer;
+    observerInfo.detectionInterval = detectionInterval;
+  }
+  return observer;
+}
+
+function setDetectionInterval(extension, context, newInterval) {
+  let observerInfo = getObserverInfo(extension, context);
+  let {observer, detectionInterval} = observerInfo;
+  if (observer) {
+    idleService.removeIdleObserver(observer, detectionInterval);
+    idleService.addIdleObserver(observer, newInterval);
+  }
+  observerInfo.detectionInterval = newInterval;
+}
 
 extensions.registerSchemaAPI("idle", "addon_parent", context => {
+  let {extension} = context;
   return {
     idle: {
       queryState: function(detectionIntervalInSeconds) {
         if (idleService.idleTime < detectionIntervalInSeconds * 1000) {
           return Promise.resolve("active");
         }
         return Promise.resolve("idle");
       },
+      setDetectionInterval: function(detectionIntervalInSeconds) {
+        setDetectionInterval(extension, context, detectionIntervalInSeconds);
+      },
+      onStateChanged: new SingletonEventManager(context, "idle.onStateChanged", fire => {
+        let listener = (event, data) => {
+          context.runSafe(fire, data);
+        };
+
+        getObserver(extension, context).on("stateChanged", listener);
+        return () => {
+          getObserver(extension, context).off("stateChanged", listener);
+        };
+      }).api(),
     },
   };
 });
--- a/toolkit/components/extensions/schemas/idle.json
+++ b/toolkit/components/extensions/schemas/idle.json
@@ -6,24 +6,24 @@
   {
     "namespace": "idle",
     "description": "Use the <code>browser.idle</code> API to detect when the machine's idle state changes.",
     "permissions": ["idle"],
     "types": [
       {
         "id": "IdleState",
         "type": "string",
-        "enum": ["active", "idle", "locked"]
+        "enum": ["active", "idle"]
       }
     ],
     "functions": [
       {
         "name": "queryState",
         "type": "function",
-        "description": "Returns \"locked\" if the system is locked, \"idle\" if the user has not generated any input for a specified number of seconds, or \"active\" otherwise.",
+        "description": "Returns \"idle\" if the user has not generated any input for a specified number of seconds, or \"active\" otherwise.",
         "async": "callback",
         "parameters": [
           {
             "name": "detectionIntervalInSeconds",
             "type": "integer",
             "minimum": 15,
             "description": "The system is considered idle if detectionIntervalInSeconds seconds have elapsed since the last user input detected."
           },
@@ -36,35 +36,33 @@
                 "$ref": "IdleState"
               }
             ]
           }
         ]
       },
       {
         "name": "setDetectionInterval",
-        "unsupported": true,
         "type": "function",
         "description": "Sets the interval, in seconds, used to determine when the system is in an idle state for onStateChanged events. The default interval is 60 seconds.",
         "parameters": [
           {
             "name": "intervalInSeconds",
             "type": "integer",
             "minimum": 15,
             "description": "Threshold, in seconds, used to determine when the system is in an idle state."
           }
         ]
       }
     ],
     "events": [
       {
         "name": "onStateChanged",
-        "unsupported": true,
         "type": "function",
-        "description": "Fired when the system changes to an active, idle or locked state. The event fires with \"locked\" if the screen is locked or the screensaver activates, \"idle\" if the system is unlocked and the user has not generated any input for a specified number of seconds, and \"active\" when the user generates input on an idle system.",
+        "description": "Fired when the system changes to an active or idle state. The event fires with \"idle\" if the the user has not generated any input for a specified number of seconds, and \"active\" when the user generates input on an idle system.",
         "parameters": [
           {
             "name": "newState",
             "$ref": "IdleState"
           }
         ]
       }
     ]
--- a/toolkit/components/extensions/test/mochitest/chrome.ini
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -21,8 +21,9 @@ skip-if = os != "mac" && os != "linux"
 [test_ext_cookies_expiry.html]
 skip-if = buildapp == 'b2g'
 [test_ext_cookies_permissions.html]
 skip-if = buildapp == 'b2g'
 [test_ext_jsversion.html]
 skip-if = buildapp == 'b2g'
 [test_ext_schema.html]
 [test_chrome_ext_storage_cleanup.html]
+[test_chrome_ext_idle.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_idle.html
@@ -0,0 +1,64 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>WebExtension test</title>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="chrome_head.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const idleService = Cc["@mozilla.org/widget/idleservice;1"].getService(Ci.nsIIdleService);
+
+add_task(function* testWithRealIdleService() {
+  function background() {
+    browser.test.onMessage.addListener((msg, ...args) => {
+      let detectionInterval = args[0];
+      if (msg == "addListener") {
+        browser.idle.queryState(detectionInterval).then(status => {
+          browser.test.assertEq("active", status, "Idle status is active");
+        });
+        browser.idle.setDetectionInterval(detectionInterval);
+        browser.idle.onStateChanged.addListener(newState => {
+          browser.test.assertEq("idle", newState, "listener fired with the expected state");
+          browser.test.sendMessage("listenerFired");
+        });
+      } else if (msg == "checkState") {
+        browser.idle.queryState(detectionInterval).then(status => {
+          browser.test.assertEq("idle", status, "Idle status is idle");
+          browser.test.notifyPass("idle");
+        });
+      }
+    });
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      permissions: ["idle"],
+    },
+  });
+
+  yield extension.startup();
+  let idleTime = idleService.idleTime;
+  let detectionInterval = Math.max(Math.ceil(idleTime / 1000) + 2, 15);
+  info(`idleTime: ${idleTime}, detectionInterval: ${detectionInterval}`);
+  extension.sendMessage("addListener", detectionInterval);
+  info("Listener added");
+  yield extension.awaitMessage("listenerFired");
+  info("Listener fired");
+  extension.sendMessage("checkState", detectionInterval);
+  yield extension.awaitFinish("idle");
+  yield extension.unload();
+});
+
+</script>
+
+</body>
+</html>
--- a/toolkit/components/extensions/test/xpcshell/test_ext_idle.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js
@@ -1,27 +1,64 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
 Cu.import("resource://testing-common/MockRegistrar.jsm");
 
 let idleService = {
+  _observers: new Set(),
+  _activity: {
+    addCalls: [],
+    removeCalls: [],
+    observerFires: [],
+  },
+  _reset: function() {
+    this._observers.clear();
+    this._activity.addCalls = [];
+    this._activity.removeCalls = [];
+    this._activity.observerFires = [];
+  },
+  _fireObservers: function(state) {
+    for (let observer of this._observers.values()) {
+      observer.observe(observer, state, null);
+      this._activity.observerFires.push(state);
+    }
+  },
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIIdleService]),
   idleTime: 19999,
+  addIdleObserver: function(observer, time) {
+    this._observers.add(observer);
+    this._activity.addCalls.push(time);
+  },
+  removeIdleObserver: function(observer, time) {
+    this._observers.delete(observer);
+    this._activity.removeCalls.push(time);
+  },
 };
 
+function checkActivity(expectedActivity) {
+  let {expectedAdd, expectedRemove, expectedFires} = expectedActivity;
+  let {addCalls, removeCalls, observerFires} = idleService._activity;
+  equal(expectedAdd.length, addCalls.length, "idleService.addIdleObserver was called the expected number of times");
+  equal(expectedRemove.length, removeCalls.length, "idleService.removeIdleObserver was called the expected number of times");
+  equal(expectedFires.length, observerFires.length, "idle observer was fired the expected number of times");
+  deepEqual(addCalls, expectedAdd, "expected interval passed to idleService.addIdleObserver");
+  deepEqual(removeCalls, expectedRemove, "expected interval passed to idleService.removeIdleObserver");
+  deepEqual(observerFires, expectedFires, "expected topic passed to idle observer");
+}
+
 add_task(function* setup() {
   let fakeIdleService = MockRegistrar.register("@mozilla.org/widget/idleservice;1", idleService);
   do_register_cleanup(() => {
     MockRegistrar.unregister(fakeIdleService);
   });
 });
 
-add_task(function* testIdleActive() {
+add_task(function* testQueryStateActive() {
   function background() {
     browser.idle.queryState(20).then(status => {
       browser.test.assertEq("active", status, "Idle status is active");
       browser.test.notifyPass("idle");
     },
     err => {
       browser.test.fail(`Error: ${err} :: ${err.stack}`);
       browser.test.notifyFail("idle");
@@ -35,17 +72,17 @@ add_task(function* testIdleActive() {
     },
   });
 
   yield extension.startup();
   yield extension.awaitFinish("idle");
   yield extension.unload();
 });
 
-add_task(function* testIdleIdle() {
+add_task(function* testQueryStateIdle() {
   function background() {
     browser.idle.queryState(15).then(status => {
       browser.test.assertEq("idle", status, "Idle status is idle");
       browser.test.notifyPass("idle");
     },
     err => {
       browser.test.fail(`Error: ${err} :: ${err.stack}`);
       browser.test.notifyFail("idle");
@@ -58,8 +95,108 @@ add_task(function* testIdleIdle() {
       permissions: ["idle"],
     },
   });
 
   yield extension.startup();
   yield extension.awaitFinish("idle");
   yield extension.unload();
 });
+
+add_task(function* testOnlySetDetectionInterval() {
+  function background() {
+    browser.idle.setDetectionInterval(99);
+    browser.test.sendMessage("detectionIntervalSet");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      permissions: ["idle"],
+    },
+  });
+
+  idleService._reset();
+  yield extension.startup();
+  yield extension.awaitMessage("detectionIntervalSet");
+  idleService._fireObservers("idle");
+  checkActivity({expectedAdd: [], expectedRemove: [], expectedFires: []});
+  yield extension.unload();
+});
+
+add_task(function* testSetDetectionIntervalBeforeAddingListener() {
+  function background() {
+    browser.idle.setDetectionInterval(99);
+    browser.idle.onStateChanged.addListener(newState => {
+      browser.test.assertEq("idle", newState, "listener fired with the expected state");
+      browser.test.sendMessage("listenerFired");
+    });
+    browser.test.sendMessage("listenerAdded");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      permissions: ["idle"],
+    },
+  });
+
+  idleService._reset();
+  yield extension.startup();
+  yield extension.awaitMessage("listenerAdded");
+  idleService._fireObservers("idle");
+  yield extension.awaitMessage("listenerFired");
+  checkActivity({expectedAdd: [99], expectedRemove: [], expectedFires: ["idle"]});
+  yield extension.unload();
+});
+
+add_task(function* testSetDetectionIntervalAfterAddingListener() {
+  function background() {
+    browser.idle.onStateChanged.addListener(newState => {
+      browser.test.assertEq("idle", newState, "listener fired with the expected state");
+      browser.test.sendMessage("listenerFired");
+    });
+    browser.idle.setDetectionInterval(99);
+    browser.test.sendMessage("detectionIntervalSet");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      permissions: ["idle"],
+    },
+  });
+
+  idleService._reset();
+  yield extension.startup();
+  yield extension.awaitMessage("detectionIntervalSet");
+  idleService._fireObservers("idle");
+  yield extension.awaitMessage("listenerFired");
+  checkActivity({expectedAdd: [60, 99], expectedRemove: [60], expectedFires: ["idle"]});
+  yield extension.unload();
+});
+
+add_task(function* testOnlyAddingListener() {
+  function background() {
+    browser.idle.onStateChanged.addListener(newState => {
+      browser.test.assertEq("active", newState, "listener fired with the expected state");
+      browser.test.sendMessage("listenerFired");
+    });
+    browser.test.sendMessage("listenerAdded");
+  }
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background,
+    manifest: {
+      permissions: ["idle"],
+    },
+  });
+
+  idleService._reset();
+  yield extension.startup();
+  yield extension.awaitMessage("listenerAdded");
+  idleService._fireObservers("active");
+  yield extension.awaitMessage("listenerFired");
+  // check that "idle-daily" topic does not cause a listener to fire
+  idleService._fireObservers("idle-daily");
+  checkActivity({expectedAdd: [60], expectedRemove: [], expectedFires: ["active", "idle-daily"]});
+  yield extension.unload();
+});