michael@0: // -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*- michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this file, michael@0: * You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: this.EXPORTED_SYMBOLS = [ "HomeProvider" ]; michael@0: michael@0: const { utils: Cu, classes: Cc, interfaces: Ci } = Components; michael@0: michael@0: Cu.import("resource://gre/modules/Messaging.jsm"); michael@0: Cu.import("resource://gre/modules/osfile.jsm"); michael@0: Cu.import("resource://gre/modules/Promise.jsm"); michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/Sqlite.jsm"); michael@0: Cu.import("resource://gre/modules/Task.jsm"); michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: michael@0: /* michael@0: * SCHEMA_VERSION history: michael@0: * 1: Create HomeProvider (bug 942288) michael@0: * 2: Add filter column to items table (bug 942295/975841) michael@0: */ michael@0: const SCHEMA_VERSION = 2; michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "DB_PATH", function() { michael@0: return OS.Path.join(OS.Constants.Path.profileDir, "home.sqlite"); michael@0: }); michael@0: michael@0: const PREF_STORAGE_LAST_SYNC_TIME_PREFIX = "home.storage.lastSyncTime."; michael@0: const PREF_SYNC_UPDATE_MODE = "home.sync.updateMode"; michael@0: const PREF_SYNC_CHECK_INTERVAL_SECS = "home.sync.checkIntervalSecs"; michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "gSyncCheckIntervalSecs", function() { michael@0: return Services.prefs.getIntPref(PREF_SYNC_CHECK_INTERVAL_SECS); michael@0: }); michael@0: michael@0: XPCOMUtils.defineLazyServiceGetter(this, michael@0: "gUpdateTimerManager", "@mozilla.org/updates/timer-manager;1", "nsIUpdateTimerManager"); michael@0: michael@0: /** michael@0: * All SQL statements should be defined here. michael@0: */ michael@0: const SQL = { michael@0: createItemsTable: michael@0: "CREATE TABLE items (" + michael@0: "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + michael@0: "dataset_id TEXT NOT NULL, " + michael@0: "url TEXT," + michael@0: "title TEXT," + michael@0: "description TEXT," + michael@0: "image_url TEXT," + michael@0: "filter TEXT," + michael@0: "created INTEGER" + michael@0: ")", michael@0: michael@0: dropItemsTable: michael@0: "DROP TABLE items", michael@0: michael@0: insertItem: michael@0: "INSERT INTO items (dataset_id, url, title, description, image_url, filter, created) " + michael@0: "VALUES (:dataset_id, :url, :title, :description, :image_url, :filter, :created)", michael@0: michael@0: deleteFromDataset: michael@0: "DELETE FROM items WHERE dataset_id = :dataset_id" michael@0: } michael@0: michael@0: /** michael@0: * Technically this function checks to see if the user is on a local network, michael@0: * but we express this as "wifi" to the user. michael@0: */ michael@0: function isUsingWifi() { michael@0: let network = Cc["@mozilla.org/network/network-link-service;1"].getService(Ci.nsINetworkLinkService); michael@0: return (network.linkType === Ci.nsINetworkLinkService.LINK_TYPE_WIFI || network.linkType === Ci.nsINetworkLinkService.LINK_TYPE_ETHERNET); michael@0: } michael@0: michael@0: function getNowInSeconds() { michael@0: return Math.round(Date.now() / 1000); michael@0: } michael@0: michael@0: function getLastSyncPrefName(datasetId) { michael@0: return PREF_STORAGE_LAST_SYNC_TIME_PREFIX + datasetId; michael@0: } michael@0: michael@0: // Whether or not we've registered an update timer. michael@0: var gTimerRegistered = false; michael@0: michael@0: // Map of datasetId -> { interval: , callback: } michael@0: var gSyncCallbacks = {}; michael@0: michael@0: /** michael@0: * nsITimerCallback implementation. Checks to see if it's time to sync any registered datasets. michael@0: * michael@0: * @param timer The timer which has expired. michael@0: */ michael@0: function syncTimerCallback(timer) { michael@0: for (let datasetId in gSyncCallbacks) { michael@0: let lastSyncTime = 0; michael@0: try { michael@0: lastSyncTime = Services.prefs.getIntPref(getLastSyncPrefName(datasetId)); michael@0: } catch(e) { } michael@0: michael@0: let now = getNowInSeconds(); michael@0: let { interval: interval, callback: callback } = gSyncCallbacks[datasetId]; michael@0: michael@0: if (lastSyncTime < now - interval) { michael@0: let success = HomeProvider.requestSync(datasetId, callback); michael@0: if (success) { michael@0: Services.prefs.setIntPref(getLastSyncPrefName(datasetId), now); michael@0: } michael@0: } michael@0: } michael@0: } michael@0: michael@0: this.HomeStorage = function(datasetId) { michael@0: this.datasetId = datasetId; michael@0: }; michael@0: michael@0: this.ValidationError = function(message) { michael@0: this.name = "ValidationError"; michael@0: this.message = message; michael@0: }; michael@0: ValidationError.prototype = new Error(); michael@0: ValidationError.prototype.constructor = ValidationError; michael@0: michael@0: this.HomeProvider = Object.freeze({ michael@0: ValidationError: ValidationError, michael@0: michael@0: /** michael@0: * Returns a storage associated with a given dataset identifer. michael@0: * michael@0: * @param datasetId michael@0: * (string) Unique identifier for the dataset. michael@0: * michael@0: * @return HomeStorage michael@0: */ michael@0: getStorage: function(datasetId) { michael@0: return new HomeStorage(datasetId); michael@0: }, michael@0: michael@0: /** michael@0: * Checks to see if it's an appropriate time to sync. michael@0: * michael@0: * @param datasetId Unique identifier for the dataset to sync. michael@0: * @param callback Function to call when it's time to sync, called with datasetId as a parameter. michael@0: * michael@0: * @return boolean Whether or not we were able to sync. michael@0: */ michael@0: requestSync: function(datasetId, callback) { michael@0: // Make sure it's a good time to sync. michael@0: if ((Services.prefs.getIntPref(PREF_SYNC_UPDATE_MODE) === 1) && !isUsingWifi()) { michael@0: Cu.reportError("HomeProvider: Failed to sync because device is not on a local network"); michael@0: return false; michael@0: } michael@0: michael@0: callback(datasetId); michael@0: return true; michael@0: }, michael@0: michael@0: /** michael@0: * Specifies that a sync should be requested for the given dataset and update interval. michael@0: * michael@0: * @param datasetId Unique identifier for the dataset to sync. michael@0: * @param interval Update interval in seconds. By default, this is throttled to 3600 seconds (1 hour). michael@0: * @param callback Function to call when it's time to sync, called with datasetId as a parameter. michael@0: */ michael@0: addPeriodicSync: function(datasetId, interval, callback) { michael@0: // Warn developers if they're expecting more frequent notifications that we allow. michael@0: if (interval < gSyncCheckIntervalSecs) { michael@0: Cu.reportError("HomeProvider: Warning for dataset " + datasetId + michael@0: " : Sync notifications are throttled to " + gSyncCheckIntervalSecs + " seconds"); michael@0: } michael@0: michael@0: gSyncCallbacks[datasetId] = { michael@0: interval: interval, michael@0: callback: callback michael@0: }; michael@0: michael@0: if (!gTimerRegistered) { michael@0: gUpdateTimerManager.registerTimer("home-provider-sync-timer", syncTimerCallback, gSyncCheckIntervalSecs); michael@0: gTimerRegistered = true; michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Removes a periodic sync timer. michael@0: * michael@0: * @param datasetId Dataset to sync. michael@0: */ michael@0: removePeriodicSync: function(datasetId) { michael@0: delete gSyncCallbacks[datasetId]; michael@0: Services.prefs.clearUserPref(getLastSyncPrefName(datasetId)); michael@0: // You can't unregister a update timer, so we don't try to do that. michael@0: } michael@0: }); michael@0: michael@0: var gDatabaseEnsured = false; michael@0: michael@0: /** michael@0: * Creates the database schema. michael@0: */ michael@0: function createDatabase(db) { michael@0: return Task.spawn(function create_database_task() { michael@0: yield db.execute(SQL.createItemsTable); michael@0: }); michael@0: } michael@0: michael@0: /** michael@0: * Migrates the database schema to a new version. michael@0: */ michael@0: function upgradeDatabase(db, oldVersion, newVersion) { michael@0: return Task.spawn(function upgrade_database_task() { michael@0: for (let v = oldVersion + 1; v <= newVersion; v++) { michael@0: switch(v) { michael@0: case 2: michael@0: // Recreate the items table discarding any michael@0: // existing data. michael@0: yield db.execute(SQL.dropItemsTable); michael@0: yield db.execute(SQL.createItemsTable); michael@0: break; michael@0: } michael@0: } michael@0: }); michael@0: } michael@0: michael@0: /** michael@0: * Opens a database connection and makes sure that the database schema version michael@0: * is correct, performing migrations if necessary. Consumers should be sure michael@0: * to close any database connections they open. michael@0: * michael@0: * @return Promise michael@0: * @resolves Handle on an opened SQLite database. michael@0: */ michael@0: function getDatabaseConnection() { michael@0: return Task.spawn(function get_database_connection_task() { michael@0: let db = yield Sqlite.openConnection({ path: DB_PATH }); michael@0: if (gDatabaseEnsured) { michael@0: throw new Task.Result(db); michael@0: } michael@0: michael@0: try { michael@0: // Check to see if we need to perform any migrations. michael@0: let dbVersion = parseInt(yield db.getSchemaVersion()); michael@0: michael@0: // getSchemaVersion() returns a 0 int if the schema michael@0: // version is undefined. michael@0: if (dbVersion === 0) { michael@0: yield createDatabase(db); michael@0: } else if (dbVersion < SCHEMA_VERSION) { michael@0: yield upgradeDatabase(db, dbVersion, SCHEMA_VERSION); michael@0: } michael@0: michael@0: yield db.setSchemaVersion(SCHEMA_VERSION); michael@0: } catch(e) { michael@0: // Close the DB connection before passing the exception to the consumer. michael@0: yield db.close(); michael@0: throw e; michael@0: } michael@0: michael@0: gDatabaseEnsured = true; michael@0: throw new Task.Result(db); michael@0: }); michael@0: } michael@0: michael@0: /** michael@0: * Validates an item to be saved to the DB. michael@0: * michael@0: * @param item michael@0: * (object) item object to be validated. michael@0: */ michael@0: function validateItem(datasetId, item) { michael@0: if (!item.url) { michael@0: throw new ValidationError('HomeStorage: All rows must have an URL: datasetId = ' + michael@0: datasetId); michael@0: } michael@0: michael@0: if (!item.image_url && !item.title && !item.description) { michael@0: throw new ValidationError('HomeStorage: All rows must have at least an image URL, ' + michael@0: 'or a title or a description: datasetId = ' + datasetId); michael@0: } michael@0: } michael@0: michael@0: var gRefreshTimers = {}; michael@0: michael@0: /** michael@0: * Sends a message to Java to refresh the given dataset. Delays sending michael@0: * messages to avoid successive refreshes, which can result in flashing views. michael@0: */ michael@0: function refreshDataset(datasetId) { michael@0: // Bail if there's already a refresh timer waiting to fire michael@0: if (gRefreshTimers[datasetId]) { michael@0: return; michael@0: } michael@0: michael@0: let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); michael@0: timer.initWithCallback(function(timer) { michael@0: delete gRefreshTimers[datasetId]; michael@0: michael@0: sendMessageToJava({ michael@0: type: "HomePanels:RefreshDataset", michael@0: datasetId: datasetId michael@0: }); michael@0: }, 100, Ci.nsITimer.TYPE_ONE_SHOT); michael@0: michael@0: gRefreshTimers[datasetId] = timer; michael@0: } michael@0: michael@0: HomeStorage.prototype = { michael@0: /** michael@0: * Saves data rows to the DB. michael@0: * michael@0: * @param data michael@0: * An array of JS objects represnting row items to save. michael@0: * Each object may have the following properties: michael@0: * - url (string) michael@0: * - title (string) michael@0: * - description (string) michael@0: * - image_url (string) michael@0: * - filter (string) michael@0: * @param options michael@0: * A JS object holding additional cofiguration properties. michael@0: * The following properties are currently supported: michael@0: * - replace (boolean): Whether or not to replace existing items. michael@0: * michael@0: * @return Promise michael@0: * @resolves When the operation has completed. michael@0: */ michael@0: save: function(data, options) { michael@0: return Task.spawn(function save_task() { michael@0: let db = yield getDatabaseConnection(); michael@0: try { michael@0: yield db.executeTransaction(function save_transaction() { michael@0: if (options && options.replace) { michael@0: yield db.executeCached(SQL.deleteFromDataset, { dataset_id: this.datasetId }); michael@0: } michael@0: michael@0: // Insert data into DB. michael@0: for (let item of data) { michael@0: validateItem(this.datasetId, item); michael@0: michael@0: // XXX: Directly pass item as params? More validation for item? michael@0: let params = { michael@0: dataset_id: this.datasetId, michael@0: url: item.url, michael@0: title: item.title, michael@0: description: item.description, michael@0: image_url: item.image_url, michael@0: filter: item.filter, michael@0: created: Date.now() michael@0: }; michael@0: yield db.executeCached(SQL.insertItem, params); michael@0: } michael@0: }.bind(this)); michael@0: } finally { michael@0: yield db.close(); michael@0: } michael@0: michael@0: refreshDataset(this.datasetId); michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: /** michael@0: * Deletes all rows associated with this storage. michael@0: * michael@0: * @return Promise michael@0: * @resolves When the operation has completed. michael@0: */ michael@0: deleteAll: function() { michael@0: return Task.spawn(function delete_all_task() { michael@0: let db = yield getDatabaseConnection(); michael@0: try { michael@0: let params = { dataset_id: this.datasetId }; michael@0: yield db.executeCached(SQL.deleteFromDataset, params); michael@0: } finally { michael@0: yield db.close(); michael@0: } michael@0: michael@0: refreshDataset(this.datasetId); michael@0: }.bind(this)); michael@0: } michael@0: };