/* 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/. */ "use strict"; /* static functions */ const DEBUG = true; function debug(aStr) { DEBUG && dump("AlarmService: " + aStr + "\n"); } const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/AlarmDB.jsm"); this.EXPORTED_SYMBOLS = ["AlarmService"]; XPCOMUtils.defineLazyGetter(this, "appsService", function() { return Cc["@mozilla.org/AppsService;1"].getService(Ci.nsIAppsService); }); XPCOMUtils.defineLazyServiceGetter(this, "ppmm", "@mozilla.org/parentprocessmessagemanager;1", "nsIMessageListenerManager"); XPCOMUtils.defineLazyGetter(this, "messenger", function() { return Cc["@mozilla.org/system-message-internal;1"] .getService(Ci.nsISystemMessagesInternal); }); XPCOMUtils.defineLazyGetter(this, "powerManagerService", function() { return Cc["@mozilla.org/power/powermanagerservice;1"] .getService(Ci.nsIPowerManagerService); }); /** * AlarmService provides an API to schedule alarms using the device's RTC. * * AlarmService is primarily used by the mozAlarms API (navigator.mozAlarms) * which uses IPC to communicate with the service. * * AlarmService can also be used by Gecko code by importing the module and then * using AlarmService.add() and AlarmService.remove(). Only Gecko code running * in the parent process should do this. */ this.AlarmService = { lastChromeId: 0, init: function init() { debug("init()"); Services.obs.addObserver(this, "profile-change-teardown", false); Services.obs.addObserver(this, "webapps-clear-data",false); this._currentTimezoneOffset = (new Date()).getTimezoneOffset(); let alarmHalService = this._alarmHalService = Cc["@mozilla.org/alarmHalService;1"].getService(Ci.nsIAlarmHalService); alarmHalService.setAlarmFiredCb(this._onAlarmFired.bind(this)); alarmHalService.setTimezoneChangedCb(this._onTimezoneChanged.bind(this)); alarmHalService.setSystemClockChangedCb( this._onSystemClockChanged.bind(this)); // Add the messages to be listened to. this._messages = ["AlarmsManager:GetAll", "AlarmsManager:Add", "AlarmsManager:Remove"]; this._messages.forEach(function addMessage(msgName) { ppmm.addMessageListener(msgName, this); }.bind(this)); // Set the indexeddb database. this._db = new AlarmDB(); this._db.init(); // Variable to save alarms waiting to be set. this._alarmQueue = []; this._restoreAlarmsFromDb(); }, // Getter/setter to access the current alarm set in system. _alarm: null, get _currentAlarm() { return this._alarm; }, set _currentAlarm(aAlarm) { this._alarm = aAlarm; if (!aAlarm) { return; } let alarmTimeInMs = this._getAlarmTime(aAlarm); let ns = (alarmTimeInMs % 1000) * 1000000; if (!this._alarmHalService.setAlarm(alarmTimeInMs / 1000, ns)) { throw Components.results.NS_ERROR_FAILURE; } }, receiveMessage: function receiveMessage(aMessage) { debug("receiveMessage(): " + aMessage.name); let json = aMessage.json; // To prevent the hacked child process from sending commands to parent // to schedule alarms, we need to check its permission and manifest URL. if (this._messages.indexOf(aMessage.name) != -1) { if (!aMessage.target.assertPermission("alarms")) { debug("Got message from a child process with no 'alarms' permission."); return null; } if (!aMessage.target.assertContainApp(json.manifestURL)) { debug("Got message from a child process containing illegal manifest URL."); return null; } } let mm = aMessage.target.QueryInterface(Ci.nsIMessageSender); switch (aMessage.name) { case "AlarmsManager:GetAll": this._db.getAll(json.manifestURL, function getAllSuccessCb(aAlarms) { debug("Callback after getting alarms from database: " + JSON.stringify(aAlarms)); this._sendAsyncMessage(mm, "GetAll", true, json.requestId, aAlarms); }.bind(this), function getAllErrorCb(aErrorMsg) { this._sendAsyncMessage(mm, "GetAll", false, json.requestId, aErrorMsg); }.bind(this)); break; case "AlarmsManager:Add": // Prepare a record for the new alarm to be added. let newAlarm = { date: json.date, ignoreTimezone: json.ignoreTimezone, data: json.data, pageURL: json.pageURL, manifestURL: json.manifestURL }; this.add(newAlarm, null, // Receives the alarm ID as the last argument. this._sendAsyncMessage.bind(this, mm, "Add", true, json.requestId), // Receives the error message as the last argument. this._sendAsyncMessage.bind(this, mm, "Add", false, json.requestId)); break; case "AlarmsManager:Remove": this.remove(json.id, json.manifestURL); break; default: throw Components.results.NS_ERROR_NOT_IMPLEMENTED; break; } }, _sendAsyncMessage: function _sendAsyncMessage(aMessageManager, aMessageName, aSuccess, aRequestId, aData) { debug("_sendAsyncMessage()"); if (!aMessageManager) { debug("Invalid message manager: null"); throw Components.results.NS_ERROR_FAILURE; } let json = null; switch (aMessageName) { case "Add": json = aSuccess ? { requestId: aRequestId, id: aData } : { requestId: aRequestId, errorMsg: aData }; break; case "GetAll": json = aSuccess ? { requestId: aRequestId, alarms: aData } : { requestId: aRequestId, errorMsg: aData }; break; default: throw Components.results.NS_ERROR_NOT_IMPLEMENTED; break; } aMessageManager.sendAsyncMessage("AlarmsManager:" + aMessageName + ":Return:" + (aSuccess ? "OK" : "KO"), json); }, _removeAlarmFromDb: function _removeAlarmFromDb(aId, aManifestURL, aRemoveSuccessCb) { debug("_removeAlarmFromDb()"); // If the aRemoveSuccessCb is undefined or null, set a dummy callback for // it which is needed for _db.remove(). if (!aRemoveSuccessCb) { aRemoveSuccessCb = function removeSuccessCb() { debug("Remove alarm from DB successfully."); }; } // Is this a chrome alarm? if (aId < 0) { aRemoveSuccessCb(); return; } this._db.remove(aId, aManifestURL, aRemoveSuccessCb, function removeErrorCb(aErrorMsg) { throw Components.results.NS_ERROR_NOT_IMPLEMENTED; }); }, /** * Create a copy of the alarm that does not expose internal fields to * receivers and sticks to the public |respectTimezone| API rather than the * boolean |ignoreTimezone| field. */ _publicAlarm: function _publicAlarm(aAlarm) { let alarm = { "id": aAlarm.id, "date": aAlarm.date, "respectTimezone": aAlarm.ignoreTimezone ? "ignoreTimezone" : "honorTimezone", "data": aAlarm.data }; return alarm; }, _fireSystemMessage: function _fireSystemMessage(aAlarm) { debug("Fire system message: " + JSON.stringify(aAlarm)); let manifestURI = Services.io.newURI(aAlarm.manifestURL, null, null); let pageURI = Services.io.newURI(aAlarm.pageURL, null, null); messenger.sendMessage("alarm", this._publicAlarm(aAlarm), pageURI, manifestURI); }, _notifyAlarmObserver: function _notifyAlarmObserver(aAlarm) { debug("_notifyAlarmObserver()"); let wakeLock = powerManagerService.newWakeLock("cpu"); let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); timer.initWithCallback(() => { debug("_notifyAlarmObserver - timeout()"); if (aAlarm.manifestURL) { this._fireSystemMessage(aAlarm); } else if (typeof aAlarm.alarmFiredCb === "function") { aAlarm.alarmFiredCb(this._publicAlarm(aAlarm)); } wakeLock.unlock(); }, 0, Ci.nsITimer.TYPE_ONE_SHOT); }, _onAlarmFired: function _onAlarmFired() { debug("_onAlarmFired()"); if (this._currentAlarm) { let currentAlarmTime = this._getAlarmTime(this._currentAlarm); // If a alarm fired before the actual time that the current // alarm should occur, we reset this current alarm. if (currentAlarmTime > Date.now()) { let currentAlarm = this._currentAlarm; this._currentAlarm = currentAlarm; this._debugCurrentAlarm(); return; } this._removeAlarmFromDb(this._currentAlarm.id, null); // We need to clear the current alarm before notifying because chrome // alarms may add a new alarm during their callback, and we do not want // to clobber it. let firingAlarm = this._currentAlarm; this._currentAlarm = null; this._notifyAlarmObserver(firingAlarm); } // Reset the next alarm from the queue. let alarmQueue = this._alarmQueue; while (alarmQueue.length > 0) { let nextAlarm = alarmQueue.shift(); let nextAlarmTime = this._getAlarmTime(nextAlarm); // If the next alarm has been expired, directly notify the observer. // it instead of setting it. if (nextAlarmTime <= Date.now()) { this._removeAlarmFromDb(nextAlarm.id, null); this._notifyAlarmObserver(nextAlarm); } else { this._currentAlarm = nextAlarm; break; } } this._debugCurrentAlarm(); }, _onTimezoneChanged: function _onTimezoneChanged(aTimezoneOffset) { debug("_onTimezoneChanged()"); this._currentTimezoneOffset = aTimezoneOffset; this._restoreAlarmsFromDb(); }, _onSystemClockChanged: function _onSystemClockChanged(aClockDeltaMS) { debug("_onSystemClockChanged"); this._restoreAlarmsFromDb(); }, _restoreAlarmsFromDb: function _restoreAlarmsFromDb() { debug("_restoreAlarmsFromDb()"); this._db.getAll(null, function getAllSuccessCb(aAlarms) { debug("Callback after getting alarms from database: " + JSON.stringify(aAlarms)); // Clear any alarms set or queued in the cache if coming from db. let alarmQueue = this._alarmQueue; if (this._currentAlarm) { alarmQueue.unshift(this._currentAlarm); this._currentAlarm = null; } for (let i = 0; i < alarmQueue.length;) { if (alarmQueue[i]['id'] < 0) { ++i; continue; } alarmQueue.splice(i, 1); } // Only restore the alarm that's not yet expired; otherwise, remove it // from the database and notify the observer. aAlarms.forEach(function addAlarm(aAlarm) { if ("manifestURL" in aAlarm && aAlarm.manifestURL && this._getAlarmTime(aAlarm) > Date.now()) { alarmQueue.push(aAlarm); } else { this._removeAlarmFromDb(aAlarm.id, null); this._notifyAlarmObserver(aAlarm); } }.bind(this)); // Set the next alarm from the queue. if (alarmQueue.length) { alarmQueue.sort(this._sortAlarmByTimeStamps.bind(this)); this._currentAlarm = alarmQueue.shift(); } this._debugCurrentAlarm(); }.bind(this), function getAllErrorCb(aErrorMsg) { throw Components.results.NS_ERROR_NOT_IMPLEMENTED; }); }, _getAlarmTime: function _getAlarmTime(aAlarm) { // Avoid casting a Date object to a Date again to // preserve milliseconds. See bug 810973. let alarmTime; if (aAlarm.date instanceof Date) { alarmTime = aAlarm.date.getTime(); } else { alarmTime = (new Date(aAlarm.date)).getTime(); } // For an alarm specified with "ignoreTimezone", it must be fired respect // to the user's timezone. Supposing an alarm was set at 7:00pm at Tokyo, // it must be gone off at 7:00pm respect to Paris' local time when the user // is located at Paris. We can adjust the alarm UTC time by calculating // the difference of the orginal timezone and the current timezone. if (aAlarm.ignoreTimezone) { alarmTime += (this._currentTimezoneOffset - aAlarm.timezoneOffset) * 60000; } return alarmTime; }, _sortAlarmByTimeStamps: function _sortAlarmByTimeStamps(aAlarm1, aAlarm2) { return this._getAlarmTime(aAlarm1) - this._getAlarmTime(aAlarm2); }, _debugCurrentAlarm: function _debugCurrentAlarm() { debug("Current alarm: " + JSON.stringify(this._currentAlarm)); debug("Alarm queue: " + JSON.stringify(this._alarmQueue)); }, /** * * Add a new alarm. This will set the RTC to fire at the selected date and * notify the caller. Notifications are delivered via System Messages if the * alarm is added on behalf of a app. Otherwise aAlarmFiredCb is called. * * @param object aNewAlarm * Should contain the following literal properties: * - |date| date: when the alarm should timeout. * - |ignoreTimezone| boolean: See [1] for the details. * - |manifestURL| string: Manifest of app on whose behalf the alarm * is added. * - |pageURL| string: The page in the app that receives the system * message. * - |data| object [optional]: Data that can be stored in DB. * @param function aAlarmFiredCb * Callback function invoked when the alarm is fired. * It receives a single argument, the alarm object. * May be null. * @param function aSuccessCb * Callback function to receive an alarm ID (number). * @param function aErrorCb * Callback function to receive an error message (string). * @returns void * * Notes: * [1] https://wiki.mozilla.org/WebAPI/AlarmAPI#Proposed_API */ add: function(aNewAlarm, aAlarmFiredCb, aSuccessCb, aErrorCb) { debug("add(" + aNewAlarm.date + ")"); aSuccessCb = aSuccessCb || function() {}; aErrorCb = aErrorCb || function() {}; if (!aNewAlarm) { aErrorCb("alarm is null"); return; } if (!aNewAlarm.date) { aErrorCb("alarm.date is null"); return; } aNewAlarm['timezoneOffset'] = this._currentTimezoneOffset; if ("manifestURL" in aNewAlarm) { this._db.add(aNewAlarm, function addSuccessCb(aNewId) { debug("Callback after adding alarm in database."); this.processNewAlarm(aNewAlarm, aNewId, aAlarmFiredCb, aSuccessCb); }.bind(this), function addErrorCb(aErrorMsg) { aErrorCb(aErrorMsg); }.bind(this)); } else { // alarms without manifests are managed by chrome code. For them we use // negative IDs. this.processNewAlarm(aNewAlarm, --this.lastChromeId, aAlarmFiredCb, aSuccessCb); } }, processNewAlarm: function(aNewAlarm, aNewId, aAlarmFiredCb, aSuccessCb) { aNewAlarm['id'] = aNewId; // Now that the alarm has been added to the database, we can tack on // the non-serializable callback to the in-memory object. aNewAlarm['alarmFiredCb'] = aAlarmFiredCb; // If the new alarm already expired at this moment, we directly // notify this alarm let newAlarmTime = this._getAlarmTime(aNewAlarm); if (newAlarmTime < Date.now()) { aSuccessCb(aNewId); this._removeAlarmFromDb(aNewAlarm.id, null); this._notifyAlarmObserver(aNewAlarm); return; } // If there is no alarm being set in system, set the new alarm. if (this._currentAlarm == null) { this._currentAlarm = aNewAlarm; this._debugCurrentAlarm(); aSuccessCb(aNewId); return; } // If the new alarm is earlier than the current alarm, swap them and // push the previous alarm back to the queue. let alarmQueue = this._alarmQueue; let currentAlarmTime = this._getAlarmTime(this._currentAlarm); if (newAlarmTime < currentAlarmTime) { alarmQueue.unshift(this._currentAlarm); this._currentAlarm = aNewAlarm; this._debugCurrentAlarm(); aSuccessCb(aNewId); return; } // Push the new alarm in the queue. alarmQueue.push(aNewAlarm); alarmQueue.sort(this._sortAlarmByTimeStamps.bind(this)); this._debugCurrentAlarm(); aSuccessCb(aNewId); }, /* * Remove the alarm associated with an ID. * * @param number aAlarmId * The ID of the alarm to be removed. * @param string aManifestURL * Manifest URL for application which added the alarm. (Optional) * @returns void */ remove: function(aAlarmId, aManifestURL) { debug("remove(" + aAlarmId + ", " + aManifestURL + ")"); this._removeAlarmFromDb(aAlarmId, aManifestURL, function removeSuccessCb() { debug("Callback after removing alarm from database."); // If there are no alarms set, nothing to do. if (!this._currentAlarm) { debug("No alarms set."); return; } // Check if the alarm to be removed is in the queue and whether it // belongs to the requesting app. let alarmQueue = this._alarmQueue; if (this._currentAlarm.id != aAlarmId || this._currentAlarm.manifestURL != aManifestURL) { for (let i = 0; i < alarmQueue.length; i++) { if (alarmQueue[i].id == aAlarmId && alarmQueue[i].manifestURL == aManifestURL) { alarmQueue.splice(i, 1); break; } } this._debugCurrentAlarm(); return; } // The alarm to be removed is the current alarm reset the next alarm // from the queue if any. if (alarmQueue.length) { this._currentAlarm = alarmQueue.shift(); this._debugCurrentAlarm(); return; } // No alarm waiting to be set in the queue. this._currentAlarm = null; this._debugCurrentAlarm(); }.bind(this)); }, observe: function(aSubject, aTopic, aData) { debug("observe(): " + aTopic); switch (aTopic) { case "profile-change-teardown": this.uninit(); break; case "webapps-clear-data": let params = aSubject.QueryInterface(Ci.mozIApplicationClearPrivateDataParams); if (!params) { debug("Error! Fail to remove alarms for an uninstalled app."); return; } // Only remove alarms for apps. if (params.browserOnly) { return; } let manifestURL = appsService.getManifestURLByLocalId(params.appId); if (!manifestURL) { debug("Error! Fail to remove alarms for an uninstalled app."); return; } this._db.getAll(manifestURL, function getAllSuccessCb(aAlarms) { aAlarms.forEach(function removeAlarm(aAlarm) { this.remove(aAlarm.id, manifestURL); }, this); }.bind(this), function getAllErrorCb(aErrorMsg) { throw Components.results.NS_ERROR_NOT_IMPLEMENTED; }); break; } }, uninit: function uninit() { debug("uninit()"); Services.obs.removeObserver(this, "profile-change-teardown"); Services.obs.removeObserver(this, "webapps-clear-data"); this._messages.forEach(function(aMsgName) { ppmm.removeMessageListener(aMsgName, this); }.bind(this)); ppmm = null; if (this._db) { this._db.close(); } this._db = null; this._alarmHalService = null; } } AlarmService.init();